Read the Tea Leaves: Recent Episodes

None

Software and other dark arts, by Nolan Lawson

View Details

All web developers know, at some level, that accessibility is important. But when push comes to shove, it can be hard to prioritize it above a bazillion other concerns when you’re trying to center a <div> and you’re on a tight deadline.

A lot of accessibility advocates lead with the moral argument: for example, that disabled people should have just as much access to the internet as any other person, and that it’s a blight on our whole industry that we continually fail to make it happen.

I personally find these arguments persuasive. But experience has also taught me that “eat your vegetables” is one of the least effective arguments in the world. Scolding people might get them to agree with you in public, or even in principle, but it’s unlikely to change their behavior once no one’s watching.

So in this post, I would like to list some of my personal, completely selfish reasons for building accessible UIs. No finger-wagging here: just good old hardheaded self-interest!

DebuggabilityWhen I’m trying to debug a web app, it’s hard to orient myself in the DevTools if the entire UI is “div soup”:

```

Library
Version
Size
UI
React
19.1.0
167kB
Style
Tailwind
4.0.0
358kB
Build
Vite
6.3.5
2.65MB

``` This is actually a table, but you wouldn’t know it from looking at the HTML:

If I’m trying to debug this in the DevTools, I’m completely lost. Where are the rows? Where are the columns?

```

Library Version Size
UI React 19.1.0 167kB
Style Tailwind 4.0.0 358kB
Build Vite 6.3.5 2.65MB

`` Ah, that’s much better! Now I can easily zero in on a table cell, or a column header, because they’re all *named*. I’m not wading through a sea of

`s anymore.

Even just adding ARIA roles to the <div>s would be an improvement here:

```

Library
Version
Size
UI
React
19.1.0
167kB
Style
Tailwind
4.0.0
358kB
Build
Vite
6.3.5
2.65MB

``` Especially if you’re using a CSS-in-JS framework (which I’ve simulated with robo-classes above), the HTML can get quite messy. Building accessibly makes it a lot easier to understand at a distance what each element is supposed to do.

Naming thingsAs all programmers know, naming things is hard. UIs are no exception: is this an “autocomplete”? Or a “dropdown”? Or a “picker”?

If you read the WAI ARIA guidelines, though, then it’s clear what it is: a “combobox”!

No need to grope for the right name: if you add the proper roles, then everything is already named for you:

  • combobox
  • listbox
  • options

As a bonus, you can use aria-* attributes or roles as a CSS selector. I often see awkward code like this:

```

``` The `active` class is clearly redundant here. If you want to style based on the `.active` selector, you could just as easily style with `[aria-selected="true"]` instead. Also, why call it `isActive` when the ARIA attribute is `aria-selected`? Just call it “selected” everywhere: ```
``` Much cleaner! I also find that thinking in terms of roles and ARIA attributes sharpens my thinking, and gives structure to the interface I’m trying to create. Suddenly, I have a *language* for what I’m building, which can lead to more “obvious” variable names, CSS custom properties, grid area names, etc. TestabilityI’ve written about this before, but building accessibly also helps with writing tests. Rather than trying to select an element based on arbitrary classes or attributes, you can write more elegant code like this (e.g. with Playwright): ``` await page.getByLabel('Name').fill('Nolan')await page.getByRole('button', { name: 'OK' }).click() ``` Imagine, though, if your entire UI is full of `
`s and robo-classes. How would you find the right inputs and buttons? You could select based on the robo-classes, or by searching for text inside or nearby the elements, but this makes your tests brittle. As Kent C. Dodds has argued, writing UI tests based on *semantics* makes your tests more resilient to change. That’s because a UI’s semantic structure (i.e. the accessibility tree) tends to change less frequently than its classes, attributes, or even the composition of its HTML elements. (How many times have you added a wrapper `
` only to break your UI tests?) Power usersWhen I’m on a desktop, I tend to be a keyboard power user. I like pressing `Esc` to close dialogs, `Enter` to submit a form, or even `/` in Firefox to quickly jump to links on the page. I do use a mouse, but I just prefer the keyboard since it’s faster. So I find it jarring when a website breaks keyboard accessibility – `Esc` doesn’t dismiss a dialog, `Enter` doesn’t submit a form, `↑`/`↓` don’t change radio buttons. It disrupts my flow when I unexpectedly have to reach for my mouse. (Plus it’s a Van Halen brown M&M that signals to me that the website probably messed something else up, too!) If you’re building a productivity tool with its own set of keyboard shortcuts (think Slack or GMail), then it’s even more important to get this right. You can’t add a lot of sophisticated keyboard controls if the basic `Tab` and focus logic doesn’t work correctly. A lot of programmers are themselves power users, so I find this argument pretty persuasive. Build a UI that you yourself would like to use! ConclusionThe reason that I, personally, care about accessibility is probably different from most people’s. I have a family member who is blind, and I’ve known many blind or low-vision people in my career. I’ve heard firsthand how frustrating it can be to use interfaces that aren’t built accessibly. Honestly, if I were disabled, I would probably think to myself, “computer programmers must not care about me.” And judging from the miserable WebAIM results, I’d clearly be right: > Across the one million home pages, 50,960,288 distinct accessibility errors were detected—an average of 51 errors per page. > > As a web developer who has dabbled in accessibility, though, I find this situation tragic. It’s not *really* that hard to build accessible interfaces. And I’m not talking about “ideal” or “optimized” – the bar is pretty low, so I’m just talking about something that *works at all* for people with a disability. Maybe in the future, accessible interfaces won’t require so much manual intervention from developers. Maybe AI tooling (on either the production or consumption side) will make UIs that are usable out-of-the-box for people with disabilities. I’m actually sympathetic to the Jakob Nielsen argument that “accessibility has failed” – it’s hard to look at the WebAIM results and come to any other conclusion. Maybe the “eat your vegetables” era of accessibility has failed, and it’s time to try new tactics. That’s why I wrote this post, though. You can build accessibly without having a bleeding heart. And for the time being, unless generative AI swoops in like a *deus ex machina* to save us, it’s our responsibility as interface designers to do so. At the same time we’re helping others, though, we can also help ourselves. Like a good hot sauce on your Brussels sprouts, eating your vegetables doesn’t always have to be a chore.

View Details

I’ve avoided writing this post for a long time, partly because I try to avoid controversial topics these days, and partly because I was waiting to make my mind up about the current, all-consuming, conversation-dominating topic of generative AI. But Steve Yegge’s “Revenge of the junior developer” awakened something in me, so let’s go for it.

I don’t come to AI from nowhere. Longtime readers may be surprised to learn that I have a master’s in computational linguistics, i.e. I studied this kind of stuff 20-odd years ago. In fact, two of the authors of the famous “stochastic parrot” paper were folks I knew at the time – Emily Bender was one of my professors, and Margaret Mitchell was my lab partner in one of our toughest classes (sorry my Python sucked at the time, Meg).

That said, I got bored of working in AI after grad school, and quickly switched to general coding. I just found that “feature engineering” (which is what we called training models at the time) was not really my jam. I much preferred to put on some good coding tunes, crank up the IDE, and bust out code all day. Plus, I had developed a dim view of natural-language processing technologies largely informed by my background in (non-computational) linguistics as an undergrad.

In linguistics, we were taught that the human mind is a wondrous thing, and that Chomsky had conclusively shown that humans have a natural language instinct. The job of the linguist is to uncover the hidden rules in the human mind that govern things like syntax, semantics, and phonology (i.e. why the “s” in “beds” is pronounced like a “z” unlike in “bets,” due to the voicing of the final consonant).

Then when I switched to computational linguistics, suddenly the overriding sensation I got was that everything was actually about number-crunching, and in fact you could throw all your linguistics textbooks in the trash and just let gobs of training data and statistics do the job for you. “Every time I fire a linguist, the performance goes up,” as a famous computational linguist said.

I found this perspective belittling and insulting to the human mind, and more importantly, it didn’t really seem to work. Natural-language processing technology seemed stuck at the level of support vector machines and conditional random fields, hardly better than the Markov models in your iPhone 2’s autocomplete. So I got bored and disillusioned and left the field of AI.

Boy, that AI thing sure came back with a vengeance, didn’t it?

Still skepticalThat said, while everybody else was either reacting with horror or delight at the tidal wave of gen-AI hype, I maintained my skepticism. At the end of the day, all of this technology was still just number-crunching – brute force trying to approximate the hidden logic that Chomsky had discovered. I acknowledged that there was some room for statistics – Peter Norvig’s essay mentioning the story of an Englishman ordering an “ale” and getting served an “eel” due to the Great Vowel Shift still sticks in my brain – but overall I doubted that mere stats could ever approach anything close to human intelligence.

Today, though, philosophical questions of what AI says about human cognition seem beside the point – these things can get stuff done. Especially in the field of coding (my cherished refuge from computational linguistics), AIs now dominate: every IDE assumes I want AI autocomplete by default, and I actively have to hunt around in the settings to turn it off.

And for several years, that’s what I’ve been doing: studiously avoiding generative AI. Not just because I doubted how close to “AGI” these things actually were, but also because I just found them annoying. I’m a fast typist, and I know JavaScript like the back of my hand, so the last thing I want is some overeager junior coder grabbing my keyboard to mess with the flow of my typing. Every inline-coding AI assistant I’ve tried made me want to gnash my teeth together – suddenly instead of writing code, I’m being asked to constantly read code (which as everyone knows, is less fun). And plus, the suggestions were rarely good enough to justify the aggravation. So I abstained.

Later I read Baldur Bjarnason’s excellent book The Intelligence Illusion, and this further hardened me against generative AI. Why use a technology that 1) dumbs down the human using it, 2) generates hard-to-spot bugs, and 3) doesn’t really make you much more productive anyway, when you consider the extra time reading, reviewing, and correcting its output? So I put in my earbuds and kept coding.

Meanwhile, as I was blissfully coding away like it was ~2020, I looked outside my window and suddenly realized that the tidal wave was approaching. It was 2025, and I was (seemingly) the last developer on the planet not using gen-AI in their regular workflow.

Opening upI try to keep an open mind about things. If you’ve read this blog for a while, you know that I’ve sometimes espoused opinions that I later completely backtracked on – my post from 10 years ago about progressive enhancement is a good example, because I’ve almost completely swung over to the progressive enhancement side of things since then. My more recent “Why I’m skeptical of rewriting JavaScript tools in ‘faster’ languages” also seems destined to age like fine milk. Maybe I’m relieved I didn’t write a big bombastic takedown of generative AI a few years ago, because hoo boy.

I started using Claude and Claude Code a bit in my regular workflow. I’ll skip the suspense and just say that the tool is way more capable than I would ever have expected. The way I can use it to interrogate a large codebase, or generate unit tests, or even “refactor every callsite to use such-and-such pattern” is utterly gobsmacking. It also nearly replaces StackOverflow, in the sense of “it can give me answers that I’m highly skeptical of,” i.e. it’s not that different from StackOverflow, but boy is it faster.

Here’s the main problem I’ve found with generative AI, and with “vibe coding” in general: it completely sucks out the joy of software development for me.

Imagine you’re a Studio Ghibli artist. You’ve spent years perfecting your craft, you love the feeling of the brush/pencil in your hand, and your life’s joy is to make beautiful artwork to share with the world. And then someone tells you gen-AI can just spit out My Neighbor Totoro for you. Would you feel grateful? Would you rush to drop your art supplies and jump head-first into the role of AI babysitter?

This is how I feel using gen-AI: like a babysitter. It spits out reams of code, I read through it and try to spot the bugs, and then we repeat. Although of course, as Cory Doctorow points out, the temptation is to not even try to spot the bugs, and instead just let your eyes glaze over and let the machine do the thinking for you – the full dream of vibe coding.

I do believe that this is the end state of this kind of development: “giving into the vibes,” not even trying to use your feeble primate brain to understand the code that the AI is barfing out, and instead to let other barf-generating “agents” evaluate its output for you. I’ll accept that maybe, maybe, if you have the right orchestra of agents that you’re conducting, then maybe you can cut down on the bugs, hallucinations, and repetitive boilerplate that gen-AI seems prone to. But whatever you’re doing at that point, it’s not software development, at least not the kind that I’ve known for the past ~20 years.

ConclusionI don’t have a conclusion. Really, that’s my current state: ambivalence. I acknowledge that these tools are incredibly powerful, I’ve even started incorporating them into my work in certain limited ways (low-stakes code like POCs and unit tests seem like an ideal use case), but I absolutely hate them. I hate the way they’ve taken over the software industry, I hate how they make me feel while I’m using them, and I hate the human-intelligence-insulting postulation that a glorified Excel spreadsheet can do what I can but better.

In one of his podcasts, Ezra Klein said that he thinks the “message” of generative AI (in the McLuhan sense) is this: “You are derivative.” In other words: all your creativity, all your “craft,” all of that intense emotional spark inside of you that drives you to dance, to sing, to paint, to write, or to code, can be replicated by the robot equivalent of 1,000 monkeys typing at 1,000 typewriters. Even if it’s true, it’s a pretty dim view of humanity and a miserable message to keep pounding into your brain during 8 hours of daily software development.

So this is where I’ve landed: I’m using generative AI, probably just “dipping my toes in” compared to what maximalists like Steve Yegge promote, but even that little bit has made me feel less excited than defeated. I am defeated in the sense that I can’t argue strongly against using these tools (they bust out unit tests way faster than I can, and can I really say that I was ever lovingly-crafting my unit tests?), and I’m defeated in the sense that I can no longer confidently assert that brute-force statistics can never approach the ineffable beauty of the human mind that Chomsky described. (If they can’t, they’re sure doing a good imitation of it.)

I’m also defeated in the sense that this very blog post is just more food for the AI god. Everything I’ve ever written on the internet (including here and on GitHub) has been eagerly gobbled up into the giant AI katamari and is being used to happily undermine me and my fellow bloggers and programmers. (If you ask Claude to generate a “blog post title in the style of Nolan Lawson,” it can actually do a pretty decent job of mimicking my shtick.) The fact that I wrote this entire post without the aid of generative AI is cold comfort – nobody cares, and likely few have gotten to the end of this diatribe anyway other than the robots.

So there’s my overwhelming feeling at the end of this post: ambivalence. I feel besieged and horrified by what gen-AI has wrought on my industry, but I can no longer keep my ears plugged while the tsunami roars outside. Maybe, like a lot of other middle-aged professionals suddenly finding their careers upended at the peak of their creative power, I will have to adapt or face replacement. Or maybe my best bet is to continue to zig while others are zagging, and to try to keep my coding skills sharp while everyone else is “vibe coding” a monstrosity that I will have to debug when it crashes in production someday.

I honestly don’t know, and I find that terrifying. But there is some comfort in the fact that I don’t think anyone else knows what’s going to happen either.

View Details

Big news for me: after 6 years, I’m leaving Salesforce to join the folks at Socket, working to secure the software supply chain.

Salesforce has been very good to me. But at a certain point, I felt the need to branch out, learn new things, and get out of my comfort zone. At Socket, I’ll be combining something I’m passionate about – the value of open source and the experience of open-source maintainers – with something less familiar to me: security. It’ll be a learning experience for sure!

In addition to learning, though, I also like sharing what I’ve learned. So I’m grateful to Salesforce for giving me a wellspring of ideas and research topics, many of which bubbled up into this very blog. Some of my “greatest hits” of the past 6 years came directly from my work at Salesforce:

Let’s learn how modern JavaScript frameworks work by building oneSalesforce builds its own JavaScript framework called Lightning Web Components, which is a little-known but surprisingly mighty tool. As part of my work on LWC, I helped modernize its architecture, which led to this post summarizing some of the trends and insights from the last ~10 years of framework design. Thanks to this work, LWC now scores pretty respectably on the js-framework-benchmark (although I still have some misgivings about the benchmark itself).

This work was also eye-opening to me, because it was my first time working as a paid open-source maintainer. Overall, I think it was a great choice on Salesforce’s part, and I wish that more companies were willing to invest in the open-source ecosystem, or at least to open up their internal tools. In LWC, we had plenty of external contributors, we got direct feedback from customers via GitHub issues, and it was easy to swap notes with other framework authors (notably in the Web Components Community Group). Plus I believe open source tends to raise the bar of quality for any project – so it’s something companies should consider for that reason alone.

A tour of JavaScript timers on the webOn the Microsoft Edge team, I learned a ton about browser internals, including little-known secrets about why certain browser APIs work the way they do. (Nothing better than “I wrote the spec, let me tell you what’s wrong with it” to get the real scoop!)

This post was a brain-dump of all the ways that JavaScript timers like setTimeout and setImmediate work across browser engines. I was intimately familiar with this topic, since Edge (not Chromium-based at the time) had been working to revamp a lot of their APIs such as Promise and fetch.

The original inspiration was a conversation I had with a colleague during my early days at Salesforce, where we debated the most performant way to batch up JavaScript work. This post still holds up pretty well today, although new fanciness like scheduler.yield() and isInputPending() isn’t covered.

Shadow DOM and accessibility: the trouble with ARIAAs part of my work at Salesforce, I was heavily involved in the Accessibility Object Model working group, partnering with folks at Igalia, Microsoft, Apple, and elsewhere to help fix problems of accessibility in web components. This led to a slew of posts on this topic, but ultimately my proudest outcome is not my own, but rather the Reference Target spec spearheaded by Ben Howell at Microsoft and now prototyped in Chromium.

After ~2 years in the working group, I was honestly starting to lose hope that we’d ever find a spec that the browser vendors could agree on. But eventually Ben joined the group, and his patience and tenacity won the day. I didn’t even contribute much (I mostly just gave feedback), but I’m still proud of what the group accomplished, and I’m hopeful that accessibility in web components will be considered a solved problem in a couple years.

My talk on CSS runtime performanceBig companies have big codebases. And big codebases can end up with a lot of CSS. Most of the time you don’t need to worry about CSS performance, but at the extremes, it can become surprisingly important.

At Salesforce, I learned way more than I ever wanted to know about how browsers handle CSS and how it affects page performance. I fixed several performance bottlenecks and regressions due to CSS (yes, a CSS change can make a page slower!), and I also filed bugs on browsers that made CSS faster for everyone. (Attributes are now roughly as fast as classes, I’m happy to say.) I also gave this fun talk at Perf.now summarizing all my findings.

Memory leaks: the forgotten side of web performanceSalesforce is a huge SPA, and as such, it has its share of memory leaks. I found that the more I looked for, the more I uncovered. At first I thought this was a Salesforce-specific issue, but then I built fuite and slowly realized that all SPAs leak. It’s more like a chronic condition to be managed than a disease to be eradicated. (If you can find an SPA without memory leaks, I’ll give you a cookie!)

I continue to maintain fuite, and I still occasionally hear from folks who have used it to fix memory leaks in their apps. Since I wrote it, Meta also released Memlab, and the Microsoft Edge team made tons of memory-related improvements to the Chromium DevTools. I still strongly feel, though, that this field is in its infancy. (Stoyan Stefanov has a great recent talk on the topic, pointing out how critical yet under-explored it is.)

The balance has shifted away from SPAsMy work on memory leaks also led me to question the value of SPAs in general. With all the improvements in browsers over the years, I came to the conclusion that MPAs are the right architecture for ~90% of web sites and apps. SPAs still have their place, but their value is dwindling year after year.

Since I wrote this post, Chrome and Safari both shipped View Transitions, and Chrome shipped Speculation Rules. With this combo, you can preload a page when the user hovers a link and then smoothly animate to it once they click. This was the whole raison d’être of SPAs in the first place, and now it’s just built into the browser.

SPAs are not going away, but their heyday is over. I think someday we’ll look back and be amazed at how much complexity we tolerated.

ConclusionI’m grateful to Salesforce and all my wonderful colleagues there, and I’m also excited to start my next chapter at Socket. More than anything, I’m excited by the crew that I’ll be joining – John-David Dalton is a former colleague from both Microsoft and Salesforce, and Feross Aboukhadijeh is someone I’ve admired for years. (I’ve spent enough hours hearing his voice on the JS Party podcast that we practically feel like old friends.)

It’s hard to predict the future, but I know that, whatever happens, I’ll be talking about it on this blog. I’ve been running this blog for 14 years through 6 different jobs, with topics ranging from NLP to Android to web development, and I don’t see myself slowing down now. Here’s to a great 2025!

View Details

2024 was another lite reading year for me. The fact that it was an election year probably didn’t help, and one of my resolutions for 2025 is to spend a heck of a lot less time keeping up with the dreary treadmill of the 24-hour news cycle. Even videogames proved to be a better use of my time, and I wouldn’t mind plugging another 100 hours into Slay the Spire next year. But without further ado, on with the books!

Quick links Nonfiction + The Inner Game of Tennis by W. Timothy Gallwey + Plagues Upon the Earth and The Fate of Rome by Kyle Harper + End Times: Elites, Counter-Elites, And The Path Of Political Disintegration by Peter Turchin + Deep Work by Cal Newport * Fiction + World Made by Hand by James Howard Kunstler + The Death of Attila and The Firedrake by Cecilia Holland + The Constant Rabbit by Jasper Fforde + Sea of Tranquility* by Emily St. John Mandel

Nonfiction The Inner Game of Tennis by W. Timothy GallweyI spent a lot of time this year competing in Super Smash Bros – going to locals, practicing my moves, and eventually competing at Seattle’s biggest-ever Smash tournament. I got 385th place out of 888 entrants, which is not too shabby given the world-class caliber of talent on display. It’s a strange use of my time if you consider videogame tournaments to be dumb, but I had a lot of fun, met some great folks, and learned a lot about competition and the esports scene.

At one of my locals, I was introduced to The Inner Game of Tennis, a sort of self-help book for tennis pros written in the 70’s. At first glance it doesn’t have much to do with videogames, but as it turns out it’s one of the best books you can read to get better at anything – sports, music, public speaking, you name it. It’s a short but dense book – there’s so much wisdom packed into so many brief, pithy sentences that you’ll probably have to re-read several paragraphs before it sinks in.

If you’re familiar with mindfulness or meditation then much of it may feel like old hat, but I still found it helpful for the immediate applications to one’s backswing (or ledgedash, in my case). It’s a good pairing with Thinking, Fast and Slow for the concept of two modes of thinking – in this case, how to quiet your conscious mind so that the wisdom of your unconscious can shine through.

Plagues Upon the Earth and The Fate Of Rome by Kyle HarperThe covid years got me interested in humanity’s experience with disease throughout history. These two books both pack a wallop, showing how disease and (maybe to a lesser extent) climate change ravaged the Roman Empire.

End Times: Elites, Counter-Elites, And The Path Of Political Disintegration by Peter TurchinTurchin’s model of how elites become complacent about “immiseration” of the poorer classes, leading to opportunistic “counter-elites” leveraging popular outrage to pursue their own power, sounds pretty familiar.

Deep Work by Cal NewportI always need a reminder that real work happens when you give yourself space and time for creativity. A good pairing with John Cleese’s classic talk.

Fiction World Made by Hand by James Howard KunstlerMy favorite fictional book I read this year. Paints a very compelling vision of a deindustrial future, but without a lot of the pessimism or nihilism that you might expect from the genre. In the end, it’s actually a very hopeful and uplifting book, and the characters are vivid and multi-textured. Strongly recommended if you like post-apocalyptic fiction or cli-fi.

The Death of Attila and The Firedrake by Cecilia HollandCecilia Holland is one of those authors whose work is bafflingly unknown. Many of her books are out-of-print, and if it hadn’t been for the recommendation of my mother, I’d have never heard of her.

If you like historical fiction, though, and if you appreciate intense attention to historical details, then her books are a great read. I love little touches like the Huns speaking Hunnish (although nobody knows what it sounded like!) or one of William the Conquerer’s knights speaking Burgundian but not (Norman) French. These are the details that a lesser author would gloss over.

I’d recommend starting with The Firedrake since it’s a bit shorter and faster-paced. I’m looking forward to devouring all of her books, regardless of which time period they’re set in.

The Constant Rabbit by Jasper FfordeA supremely silly book, and occasionally a bit too cliché and on-the-nose with its metaphors, but still a fun read. If you like Douglas Adams or Kurt Vonnegut then you’ll probably find a lot to enjoy in its dry humor.

Sea of Tranquility by Emily St. John MandelI’m always a bit disappointed when speculative fiction assumes the same customs and culture of our time but transplants them onto a whiz-bang sci-fi future – just a change of scenery. But some of the time travel and metaphysical bits in this book are pretty neat. It’s a bit like a compressed Cloud Atlas in how it’s structured.

View Details

In a previous post, I said that a web component’s connectedCallback and disconnectedCallback should be mirror images of each other: one for setup, the other for cleanup.

Sometimes, though, you want to avoid unnecessary cleanup work when your component has merely been moved around in the DOM:

div.removeChild(component)div.insertBefore(component, null) This can happen when, for example, your component is one element in a list that’s being re-sorted.

The best pattern I’ve found for handling this is to queue a microtask in disconnectedCallback before checking this.isConnected to see if you’re still disconnected:

async disconnectedCallback() { await Promise.resolve() if (!this.isConnected) { // cleanup logic }} Of course, you’ll also want to avoid repeating your setup logic in connectedCallback, since it will fire as well during a reconnect. So a complete solution would look like:

connectedCallback() { // setup logic this.\_isSetUp = true}async disconnectedCallback() { await Promise.resolve() if (!this.isConnected && this.\_isSetUp) { // cleanup logic this.\_isSetUp = false }} For what it’s worth, Solid, Svelte, and Vue all use this pattern when compiled as web components.

If you’re clever, you might think that you don’t need the microtask, and can merely check this.isConnected. However, this only works in one particular case: if your component is inserted (e.g. with insertBefore/appendChild) but not removed first (e.g. with removeChild). In that case, isConnected will be true during disconnectedCallback, which is quite counter-intuitive:

However, this is not the case if removeChild is called during the “move”:

You can’t really predict how your component will be moved around, so sadly you have to handle both cases. Hence the microtask.

In the future, this may change slightly. There is a proposal to add a new moveBefore method, which would invoke a special connectedMoveCallback. However, this is still behind a flag in Chromium, and the API has not been finalized, so I’ll avoid commenting on it further.

This post was inspired by a discussion in the Web Components Community Group Discord with Filimon Danopoulos, Justin Fagnani, and Rob Eisenberg.

View Details

I’ve written a lot of JavaScript. I like JavaScript. And more importantly, I’ve built up a set of skills in understanding, optimizing, and debugging JavaScript that I’m reluctant to give up on.

So maybe it’s natural that I get a worried pit in my stomach over the current mania to rewrite every Node.js tool in a “faster” language like Rust, Zig, Go, etc. Don’t get me wrong – these languages are cool! (I’ve got a copy of the Rust book on my desk right now, and I even contributed a bit to Servo for fun.) But ultimately, I’ve invested a ton of my career in learning the ins and outs of JavaScript, and it’s by far the language I’m most comfortable with.

So I acknowledge my bias (and perhaps over-investment in one skill set). But the more I think about it, the more I feel that my skepticism is also justified by some real objective concerns, which I’d like to cover in this post.

PerformanceOne reason for my skepticism is that I just don’t think we’ve exhausted all the possibilities of making JavaScript tools faster. Marvin Hagemeister has done an excellent job of demonstrating this, by showing how much low-hanging fruit there is in ESLint, Tailwind, etc.

In the browser world, JavaScript has proven itself to be “fast enough” for most workloads. Sure, WebAssembly exists, but I think it’s fair to say that it’s mostly used for niche, CPU-intensive tasks rather than for building a whole website. So why are JavaScript-based CLI tools rushing to throw JavaScript away?

The big rewriteI think the perf gap comes from a few different things. First, there’s the aforementioned low-hanging fruit – for a long time, the JavaScript tooling ecosystem has been focused on building something that works, not something fast. Now we’ve reached a saturation point where the API surface is mostly settled, and everyone just wants “the same thing, but faster.” Hence the explosion of new tools that are nearly drop-in replacements for existing ones: Rolldown for Rollup, Oxlint for ESLint, Biome for Prettier, etc.

However, these tools aren’t necessarily faster because they’re using a faster language. They could just be faster because 1) they’re being written with performance in mind, and 2) the API surface is already settled, so the authors don’t have to spend development time tinkering with the overall design. Heck, you don’t even need to write tests! Just use the existing test suite from the previous tool.

In my career, I’ve often seen a rewrite from A to B resulting in a speed boost, followed by the triumphant claim that B is faster than A. However, as Ryan Carniato points out, a rewrite is often faster just because it’s a rewrite – you know more the second time around, you’re paying more attention to perf, etc.

Bytecode and JITThe second class of performance gaps comes from the things browsers give us for free, and that we rarely think about: the bytecode cache and JIT (Just-In-Time compiler).

When you load a website for the second or third time, if the JavaScript is cached correctly, then the browser doesn’t need to parse and compile the source code into bytecode anymore. It just loads the bytecode directly off disk. This is the bytecode cache in action.

Furthermore, if a function is “hot” (frequently executed), it will be further optimized into machine code. This is the JIT in action.

In the world of Node.js scripts, we don’t get the benefits of the bytecode cache at all. Every time you run a Node script, the entire script has to be parsed and compiled from scratch. This is a big reason for the reported perf wins between JavaScript and non-JavaScript tooling.

Thanks to the inimitable Joyee Cheung, though, Node is now getting a compile cache. You can set an environment variable and immediately get faster Node.js script loads:

export NODE\_COMPILE\_CACHE=~/.cache/nodejs-compile-cache I’ve set this in my ~/.bashrc on all my dev machines. I hope it makes it into the default Node settings someday.

As for JIT, this is another thing that (sadly) most Node scripts can’t really benefit from. You have to run a function before it becomes “hot,” so on the server side, it’s more likely to kick in for long-running servers than for one-off scripts.

And the JIT can make a big difference! In Pinafore, I considered replacing the JavaScript-based blurhash library with a Rust (Wasm) version, before realizing that the performance difference was erased by the time we got to the fifth iteration. That’s the power of the JIT.

Maybe eventually a tool like Porffor could be used to do an AOT (Ahead-Of-Time) compilation of Node scripts. In the meantime, though, JIT is still a case where native languages have an edge on JavaScript.

I should also acknowledge: there is a perf hit from using Wasm versus pure-native tools. So this could be another reason native tools are taking the CLI world by storm, but not necessarily the browser frontend.

Contributions and debuggabilityI hinted at it earlier, but this is the main source of my skepticism toward the “rewrite it all in native” movement.

JavaScript is, in my opinion, a working-class language. It’s very forgiving of types (this is one reason I’m not a huge TypeScript fan), it’s easy to pick up (compared to something like Rust), and since it’s supported by browsers, there is a huge pool of people who are conversant with it.

For years, we’ve had both library authors and library consumers in the JavaScript ecosystem largely using JavaScript. I think we take for granted what this enables.

For one: the path to contribution is much smoother. To quote Matteo Collina:

Most developers ignore the fact that they have the skills to debug/fix/modify their dependencies. They are not maintained by unknown demigods but by fellow developers.

This breaks down if JavaScript library authors are using languages that are different (and more difficult!) than JavaScript. They may as well be demigods!

For another thing: it’s straightforward to modify JavaScript dependencies locally. I’ve often tweaked something in my local node_modules folder when I’m trying to track down a bug or work on a feature in a library I depend on. Whereas if it’s written in a native language, I’d need to check out the source code and compile it myself – a big barrier to entry.

(To be fair, this has already gotten a bit tricky thanks to the widespread use of TypeScript. But TypeScript is not too far from the source JavaScript, so you’d be amazed how far you can get by clicking “pretty print” in the DevTools. Thankfully most Node libraries are also not minified.)

Of course, this also leads us back to debuggability. If I want to debug a JavaScript library, I can simply use the browser’s DevTools or a Node.js debugger that I’m already familiar with. I can set breakpoints, inspect variables, and reason about the code as I would for my own code. This isn’t impossible with Wasm, but it requires a different skill set.

ConclusionI think it’s great that there’s a new generation of tooling for the JavaScript ecosystem. I’m excited to see where projects like Oxc and VoidZero end up. The existing incumbents are indeed exceedingly slow and would probably benefit from the competition. (I get especially peeved by the typical eslint + prettier + tsc + rollup lint+build cycle.)

That said, I don’t think that JavaScript is inherently slow, or that we’ve exhausted all the possibilities for improving it. Sometimes I look at truly perf-focused JavaScript, such as the recent improvements to the Chromium DevTools using mind-blowing techniques like using Uint8Arrays as bit vectors, and I feel that we’ve barely scratched the surface. (If you really want an inferiority complex, see other commits from Seth Brenith. They are wild.)

I also think that, as a community, we have not really grappled with what the world would look like if we relegate JavaScript tooling to an elite priesthood of Rust and Zig developers. I can imagine the average JavaScript developer feeling completely hopeless every time there’s a bug in one of their build tools. Rather than empowering the next generation of web developers to achieve more, we might be training them for a career of learned helplessness. Imagine what it will feel like for the average junior developer to face a segfault rather than a familiar JavaScript Error.

At this point, I’m a senior in my career, so of course I have little excuse to cling to my JavaScript security-blanket. It’s part of my job to dig down a few layers deeper and understand how every part of the stack works.

However, I can’t help but feel like we are embarking down an unknown path with unintended consequences, when there is another path that is less fraught and could get us nearly the same results. The current freight train shows no signs of slowing down, though, so I guess we’ll find out when we get there.

View Details

I love the js-framework-benchmark. It’s a true open-source success story – a shared benchmark, with contributions from various JavaScript framework authors, widely cited, and used to push the entire JavaScript ecosystem forward. It’s a rare marvel.

That said, the benchmark is so good that it’s sometimes taken as the One True Measure of a web framework’s performance (or maybe even worth!). But like any metric, it has its flaws and limitations. Many of these limitations are well-known among framework authors like myself, but aren’t widely known outside a small group of experts.

In this post, I’d like to both celebrate the js-framework-benchmark for its considerable achievements, while also highlighting some of its quirks and limitations.

The greatnessFirst off, I want to acknowledge the monumental work that Stefan Krause has put into the js-framework-benchmark. It’s practically a one-man show – if you look into the commit history, it’s clear that Stefan has shouldered the main burden of maintaining the benchmark over time.

This is not a simple feat! A recent subtle issue with Chrome 124 shows just how much work goes into keeping even a simple benchmark humming across major browser releases.

So I don’t want anything in this post to come across as an attack on Stefan or the js-framework-benchmark. I am the king of burning out on open-source projects (PouchDB, Pinafore), so I have no leg to stand on to criticize an open-source maintainer with such tireless dedication. I can only sit in awe of Stefan’s accomplishment. I’m humbled and grateful.

If anything, this post should underscore how utterly the benchmark has succeeded under Stefan’s stewardship. Despite its flaws (as any benchmark would have), the js-framework-benchmark has become almost synonymous with “JavaScript framework performance.” To me, this is almost entirely due to Stefan’s diligence and attention to detail. Under different leadership, the benchmark may have been forgotten by now.

So within that context, I’d like to talk about the things the benchmark doesn’t measure, as well as the things it measures slightly differently from how folks might expect.

What does the benchmark do exactly?First off, we have to understand what the js-framework-benchmark actually tests.

Screenshot of the vanillajs (i.e. baseline) “framework” in the js-framework-benchmark

To oversimplify, the core benchmark is:

  • Render a `with up to 10k rows* Add rows, mutate a row, remove rows, etc. This is basically it. Frameworks are judged on how fast they can render 10k table rows, mutate a single row, swap some rows around, etc.

If this sounds like a very specific scenario, well… it kind of is. And this is where the main limitations of the benchmark come in. Let’s cover each one separately.

SSR and hydrationMost clearly, the js-framework-benchmark does not measure server-side rendering (SSR) or hydration. It is purely focused on client-side rendering (CSR).

This is fine, by the way! Plenty of web apps are pure-CSR Single-Page Apps (SPAs). And there are other benchmarks that do cover SSR, such as Marko’s isomorphic UI benchmarks.

This is just to say that, for frameworks that almost exclusively focus on the performance benefits they bring to SSR or hydration (such as Qwik or Astro), the js-framework-benchmark is not really going to tell you how they stack up to other frameworks. The main value proposition is just not represented here.

One big componentThe js-framework-benchmark typically renders one big component. There are some exceptions, such as the vanillajs-wc “framework” using multiple web components. But in general, most of the frameworks you’ve heard of render one big component containing the entire table and all its rows and cells.

There is nothing inherently wrong with this. However, it means that any per-component overhead (such as the overhead inherent to web components, or the overhead of the framework’s component abstraction) is not captured in the benchmark. And of course, any future optimizations that frameworks might do to reduce per-component overhead will never win points on the js-framework-benchmark.

Again, this is fine. Sometimes the ideal implementation is “one big component.” However, it’s not very common, so this is something to be aware of when reading the benchmark results.

Optimized by framework authorsFramework authors are a competitive bunch. Even framework users are defensive about their chosen framework. So it’s no surprise that what you’re seeing in the js-framework-benchmark has been heavily optimized to put each framework in the best possible light.

Sometimes this is reasonable – after all, the benchmark should try to represent what a competent component author would write. In other cases… it’s more of a gray zone.

I don’t want to demonize any particular framework in this post. So I’m going to call out a few cases I’ve seen of this, including one from the framework I work on (LWC).

+ Svelte’s HTML was originally written in an awkward style designed to eliminate the overhead of inserting whitespace text nodes. To Svelte’s credit, the new Svelte v5 code does not have this issue.
+ Vue introduced the `v-memo` directive (at least in part) to improve their score on the `js-framework-benchmark` by minimizing the overhead of virtual DOM diffing in the “select row” test (i.e. updating one row out of 1k). However, this could be construed as unfair, since `v-memo` is an advanced directive that only performance-minded component authors are likely to use. Whereas other frameworks can be just as competitive with only idiomatic component authoring patterns.
+ Event delegation is a whole can of worms. Some frameworks (such as Solid and Svelte) do automatic event delegation, which boosts performance without requiring developer intervention. Other frameworks in the benchmark, such as Million and Lit, use manual delegation, which again is a bit unfair because it’s not something a component author will necessarily think to do. (The LWC component uses mild manual event delegation, by placing one listener on each row instead of two.) This can make a big difference in the benchmark, especially since the `vanillajs` “framework” (i.e. the baseline) uses event delegation, so you kind of have to do it to be truly competitive, unless you want to be penalized for adding 20k `click` listeners instead of one.Again, none of this is necessarily good or bad. Event delegation is a worthy technique, `v-memo` is a great optimization for those who know to use it, and as a Svelte user I’ve even worked around the whitespace issue myself. Some of these points (such as event delegation) are even noted in the benchmark results. But I’d wager that most folks reading the benchmark are not aware of these subtleties.

10k rows is a lotThe benchmark renders 1k-10k table rows, with 7 elements inside each one. Then it tests mutating, removing, or swapping those rows.

Frameworks that do well on this scenario are (frankly) amazing. However, that doesn’t change the fact that this is a very weird scenario. If you are rendering 8k-80k DOM elements, then you should probably start thinking about pagination or virtualization (or at least content-visibility). Putting that many elements in the same component is also not something you see in most web apps.

Because this is such an atypical scenario, it also exaggerates the benefit of certain optimizations, such as the aforementioned event delegation. If you are attaching one event listener instead of 20k, then yes, you are going to be measurably faster. But should you really ever put yourself in a situation where you’re creating 20k event listeners on 80k DOM elements in the first place?

Chrome-onlyOne of my biggest pet peeves is when web developers only pay attention to Chrome while ignoring other browsers. Especially in performance discussions, statements like “Such-and-such DOM API is fast” or “The browser is slow at X,” where Chrome is merely implied, really irk me. This is something I railed against in my tenure on the Microsoft Edge team.

Focusing on one browser does kind of make sense in this case, though, since the js-framework-benchmark relies on some advanced Chromium APIs to run the tests. It also makes the results easier to digest, since there’s only one browser in play.

However, Chrome is not the only browser that exists (a fact that may surprise some web developers). So it’s good to be aware that this benchmark has nothing to say about Firefox or Safari performance.

Only measuring re-rendersAs mentioned above, the js-framework-benchmark measures client-side rendering. Bundle size and memory usage are tracked as secondary measures, but they are not the main thing being measured, and I rarely see them mentioned. For most people, the runtime metrics are the benchmark.

Additionally, the bootstrap cost of a framework – i.e. the initial cost to execute the framework code itself – is not measured. Combine this with the lack of SSR/hydration coverage, and the js-framework-benchmark probably cannot tell you if a framework will tank your Largest Contentful Paint (LCP) or Total Blocking Time (TBT) scores, since it does not measure the first page load.

However, this lack of coverage for first-render goes even deeper. To avoid variance, the js-framework-benchmark does 5 “warmup” iterations before most tests. This means that many more first-render costs are not measured:

+ Pre-JITed (Just-In-Time compilation) performance
+ Initial one-time framework costsFor those unaware, JavaScript engines will JIT any code that they detect as “hot” (i.e. frequently executed). By doing 5 warmup iterations, we effectively skip past the pre-JITed phase and measure the JITed code directly. (This is also called “peak performance.”) However, the JITed performance is not necessarily what your users are experiencing, since every user has to experience the pre-JITed code before they can get to the JIT!

This second point above is also important. As mentioned in a previous post, lots of next-gen frameworks use a pattern where they set the innerHTML on a`

View Details

Every so often, the web development community gets into a tizzy about something, usually web components. I find these fights tiresome, but I also see them as a good opportunity to reach across “the great divide” and try to find common ground rather than another opportunity to dunk on each other.

Ryan Carniato started the latest round with “Web Components Are Not the Future”. Cory LaViska followed up with “Web Components Are Not the Future — They’re the Present”. I’m not here to escalate, though – this is a peace mission.

I’ve been an avid follower of Ryan Carniato’s work for years. This post and the steady climb of LWC on the js-framework-benchmark demonstrate that I’ve been paying attention to what he has to say, especially about performance and framework design. The guy has single-handedly done more to move the web framework ecosystem forward in the past 5 years than anyone else I can think of.

That said, I also heavily work with web components, both on the framework side and as a component author. I’ve participated in the Web Components Community Group and Accessibility Object Model group, and I’ve written extensively on shadow DOM, custom elements, and web component accessibility in this blog.

So obviously I’m going to be interested when I see a post from Ryan Carniato on web components. And it’s a thought-provoking post! But I also think he misses the mark on a few things. So let’s dive in:

Performance

[T]he fundamental problem with Web Components is that they are built on Custom Elements.

[…] [E]very interface needs to go through the DOM. And of course this has a performance overhead.

This is completely true. If your goal is to build the absolute fastest framework you can, then you want to minimize DOM nodes wherever possible. This means that web components are off the table.

I fully believe that Ryan knows how to build the fastest possible framework. Again, the results for Solid on the js-framework-benchmark are a testament to this.

That said – and I might alienate some of my friends in the web performance community by saying this – performance isn’t everything. There are other tradeoffs in software development, such as maintainability, security, usability, and accessibility. Sometimes these things come into conflict.

To make a silly example: I could make DOM rendering slightly faster by never rendering any aria-* attributes. But of course sometimes you have to render aria-* attributes to make your interface accessible, and nobody would argue that a couple milliseconds are worth excluding screen reader users.

To make an even sillier example: you can improve performance by using for loops instead of .forEach(). Or using var instead of const/let. Typically, though, these kinds of micro-optimizations are just not worth it.

When I see this kind of stuff, I’m reminded of speedrunners trying to shave milliseconds off a 5-minute run of Super Mario Bros using precise inputs and obscure glitches. If that’s your goal, then by all means: backwards long jump across the entire stage instead of just having Mario run forward. I’ll continue to be impressed by what you’re doing, but it’s just not for me.

Minimizing the use of DOM nodes is a classic optimization – this is the main idea behind virtualization. That said, sometimes you can get away with simpler approaches, even if it’s not the absolute fastest option. I’d put “components as elements” in the same bucket – yes it’s sub-optimal, but optimal is not always the goal.

Similarly, I’ve long argued that it’s fine for custom elements to use different frameworks. Sometimes you just need to gradually migrate from Framework A to Framework B. Or you have to compose some micro-frontends together. Nobody would argue that this is the fastest possible interface, but fine – sometimes tradeoffs have to be made.

Having worked for a long time in the web performance space, I find that the lowest-hanging fruit for performance is usually something dumb like layout thrashing, network waterfalls, unnecessary re-renders, etc. Framework authors like myself love to play performance golf with things like the js-framework-benchmark, and it’s a great flex, but it just doesn’t usually matter in the real world.

That said, if it does matter to you – if you’re building for resource-constrained environments where every millisecond counts: great! Ditch web components! I will geek out and cheer for every speedrunning record you break.

The cost of standards

More code to ship and more code to execute to check these edge cases. It’s a hidden tax that impacts everyone.

Here’s where I completely get off the train from Ryan’s argument. As a framework author, I just don’t find that it’s that much effort to support web components. Detecting props versus attributes is a simple prop in element check. Outputting web components is indeed painful, but hey – nobody said you have to do it. Vue 2 got by with a standalone web component wrapper library, and Remount exists without any input from the React team.

As a framework author, if you want to freeze your thinking in 2011 and code as if nothing new was added to the web platform since then, you absolutely can! And you can still write a great framework! This is the beauty of the web. jQuery v1 is still chugging away on plenty of websites, and in fact it gets faster and faster with every new browser release, since browser perf teams are often targeting whatever patterns web developers used ~5 year ago in an endless cat-and-mouse game.

But assuming you don’t want to freeze your brain in amber, then yes: you do need to account for new stuff added to the web platform. But this is also true of things like Symbols, Proxys, Promises, etc. I just see it as part of the job, and I’m not particularly bothered, since I know that whatever I write will still work in 10 years, thanks to the web’s backwards compatibility guarantees.

Furthermore, I get the impression that a wide swath of the web development community does not care about web components, does not want to support them, and you probably couldn’t convince them to. And that’s okay! The web is a big tent, and you can build entire UIs based on web components, or with a sprinkling of HTML web components, or with none at all. If you want to declare your framework a “no web components” zone, then you can do that and still get plenty of avid fans.

That said, Ryan is right that, by blessing something as “the standard,” it inherently becomes a mental default that needs to be grappled with. Component authors must decide whether their <slot>s should work like native <slot>s. That’s true, but again, you could say this about a lot of new browser APIs. You have to decide whether IntersectionObserver or <img loading="lazy"> is worth it, or whether you’d rather write your own abstraction. That’s fine! At least we have a common point of reference, a shared vocabulary to compare and contrast things.

And just because something is a web standard doesn’t mean you have to use it. For the longest time, the classic joke about JavaScript: The Good Parts was how small it is compared to JavaScript: The Definitive Guide. The web is littered with deprecated (but still supported) APIs like document.domain, with, and <frame>s. Take it or leave it!

Conclusion

[I]n a sense there are nothing wrong with Web Components as they are only able to be what they are. It’s the promise that they are something that they aren’t which is so dangerous.

Here I totally agree with Ryan. As I’ve said before, web components are bad at a lot of things – Server-Side Rendering, accessibility, even interop in some cases. They’re good at plenty of things, but replacing all JavaScript frameworks is not one of them. Maybe we can check back in 10 years, but for now, there are still cases where React, Solid, Svelte, and friends shine and web components flounder.

Ryan is making an eminently reasonable point here, as is the rest of the post, and on its own it’s a good contribution to the discourse. The title is a bit inflammatory, which leads people to wield it as a bludgeon against their perceived enemies on social media (likely without reading the piece), but this is something I blame on social media, not on Ryan.

Again, I find these debates a bit tiresome. I think the fundamental issue, as I’ve previously said, is that people are talking past each other because they’re building different things with different constraints. It’s as if a salsa dancer criticized ballet for not being enough like salsa. There is more than one way to dance!

From my own personal experience: at Salesforce, we build a client-rendered app, with its own marketplace of components, with strict backwards-compatibility guarantees, where the intended support is measured in years if not decades. Is this you? Then maybe you shouldn’t build your entire UI out of web components, with shadow DOM and the whole kit-n-kaboodle. (Or maybe you should! I can’t say!)

What I find exciting about the web is the sheer number of people doing so many wild and bizarre things with it. It has everything from games to art projects to enterprise SaaS apps, built with WebGL and Wasm and Service Workers and all sorts of zany things. Every new capability added to the web platform isn’t a limitation on your creativity – it’s an opportunity to express your creativity in ways that nobody imagined before.

Web components may not be the future for you – that’s great! I’m excited to see what you build, and I might steal some ideas for my own corner of the web.

View Details

Recently I got an interesting performance bug on emoji-picker-element:

I’m on a fedi instance with 19k custom emojis […] and when I open the emoji picker […], the page freezes for like a full second at least and overall performance stutters for a while after that.

If you’re not familiar with Mastodon or the Fediverse, different servers can have their own custom emoji, similar to Slack, Discord, etc. Having 19k (really closer to 20k in this case) is highly unusual, but not unheard of.

So I booted up their repro, and holy moly, it was slow:

There were multiple things wrong here:

  • 20k custom emoji meant 40k elements, since each one used a and an .
  • No virtualization was used, so all these elements were just shoved into the DOM.

Now, to my credit, I was using , so those 20k images were not all being downloaded at once. But no matter what, it’s going to be achingly slow to render 40k elements – Lighthouse recommends no more than 1,400!

My first thought, of course, was, “Who the heck has 20k custom emoji?” My second thought was, “Sigh I guess I’m going to need to do virtualization.”

I had studiously avoided virtualization in emoji-picker-element, namely because 1) it’s complex, 2) I didn’t think I needed it, and 3) it has implications for accessibility.

I’ve been down this road before: Pinafore is basically one big virtual list. I used the ARIA feed role, did all the calculations myself, and added an option to disable “infinite scroll,” since some people don’t like it. This is not my first rodeo! I was just grimacing at all the code I’d have to write, and wondering about the size impact on my “tiny” ~12kB emoji picker.

After a few days, though, the thought popped into my head: what about CSS content-visibility? I saw from the trace that lots of time is spent in layout and paint, and plus this might help the “stuttering.” This could be a much simpler solution than full-on virtualization.

If you’re not familiar, content-visibility is a new-ish CSS feature that allows you to “hide” certain parts of the DOM from the perspective of layout and paint. It largely doesn’t affect the accessibility tree (since the DOM nodes are still there), it doesn’t affect find-in-page (+F/Ctrl+F), and it doesn’t require virtualization. All it needs is a size estimate of off-screen elements, so that the browser can reserve space there instead.

Luckily for me, I had a good atomic unit for sizing: the emoji categories. Custom emoji on the Fediverse tend to be divided into bite-sized categories: “blobs,” “cats,” etc.

Custom emoji on mastodon.social.

For each category, I already knew the emoji size and the number of rows and columns, so calculating the expected size could be done with CSS custom properties:

.category { content-visibility: auto; contain-intrinsic-size: /* width */ calc(var(--num-columns) * var(--total-emoji-size)) /* height */ calc(var(--num-rows) * var(--total-emoji-size));} These placeholders take up exactly as much space as the finished product, so nothing is going to jump around while scrolling.

The next thing I did was write a Tachometer benchmark to track my progress. (I love Tachometer.) This helped validate that I was actually improving performance, and by how much.

My first stab was really easy to write, and the perf gains were there… They were just a little disappointing.

For the initial load, I got a roughly 15% improvement in Chrome and 5% in Firefox. (Safari only has content-visibility in Technology Preview, so I can’t test it in Tachometer.) This is nothing to sneeze at, but I knew a virtual list could do a lot better!

So I dug a bit deeper. The layout costs were nearly gone, but there were still other costs that I couldn’t explain. For instance, what’s with this big undifferentiated blob in the Chrome trace?

Whenever I feel like Chrome is “hiding” some perf information from me, I do one of two things: bust out chrome:tracing, or (more recently) enable the experimental “show all events” option in DevTools.

This gives you a bit more low-level information than a standard Chrome trace, but without needing to fiddle with a completely different UI. I find it’s a pretty good compromise between the Performance panel and chrome:tracing.

And in this case, I immediately saw something that made the gears turn in my head:

What the heck is ResourceFetcher::requestResource? Well, even without searching the Chromium source code, I had a hunch – could it be all those s? It couldn’t be, right…? I’m using !

Well, I followed my gut and simply commented out the src from each , and what do you know – all those mystery costs went away!

I tested in Firefox as well, and this was also a massive improvement. So this led me to believe that loading="lazy" was not the free lunch I assumed it to be.

At this point, I figured that if I was going to get rid of loading="lazy", I may as well go whole-hog and turn those 40k DOM elements into 20k. After all, if I don’t need an , then I can use CSS to just set the background-image on an ::after pseudo-element on the , cutting the time to create those elements in half. .onscreen .custom-emoji::after { background-image: var(--custom-emoji-background);} At this point, it was just a simple IntersectionObserver to add the onscreen class when the category scrolled into view, and I had a custom-made loading="lazy" that was much more performant. This time around, Tachometer reported a ~40% improvement in Chrome and ~35% improvement in Firefox. Now that’s more like it!

Note: I could have used the contentvisibilityautostatechange event instead of IntersectionObserver, but I found cross-browser differences, and plus it would have penalized Safari by forcing it to download all the images eagerly. Once browser support improves, though, I’d definitely use it!

I felt good about this solution and shipped it. All told, the benchmark clocked a ~45% improvement in both Chrome and Firefox, and the original repro went from ~3 seconds to ~1.3 seconds. The person who reported the bug even thanked me and said that the emoji picker was much more usable now.

Something still doesn’t sit right with me about this, though. Looking at the traces, I can see that rendering 20k DOM nodes is just never going to be as fast as a virtualized list. And if I wanted to support even bigger Fediverse instances with even more emoji, this solution would not scale.

I am impressed, though, with how much you get “for free” with content-visibility. The fact that I didn’t need to change my ARIA strategy at all, or worry about find-in-page, was a godsend. But the perfectionist in me is still irritated by the thought that, for maximum perf, a virtual list is the way to go.

Maybe eventually the web platform will get a real virtual list as a built-in primitive? There were some efforts at this a few years ago, but they seem to have stalled.

I look forward to that day, but for now, I’ll admit that content-visibility is a good rough-and-ready alternative to a virtual list. It’s simple to implement, gives a decent perf boost, and has essentially no accessibility footguns. Just don’t ask me to support 100k custom emoji!

View Details

Pop quiz: what emoji do you see below? [1]

Depending on your browser and operating system, you might see:

  • The flag of Martinique
  • The old flag of Martinique (which kinda looks like the Quebecois flag)
  • The enigmatic initials “MQ”

From left to right: Safari on iOS 17, Firefox 130 on Windows 11, and Chrome 128 on Windows 11.

This, frankly, is a mess. And it’s emblematic of how half-heartedly browsers and operating systems have worked to keep their emoji up to date.

What’s responsible for this sorry state? I gave an overview two years ago, and shockingly little has changed – in fact, it’s gotten a bit worse.

In short:

  • Firefox bundles their own emoji font (great!), but unfortunately, thanks to turmoil at Twitter/X, their bet on Twemoji has not shaken out too well, and they are two years behind the latest Unicode standard.
  • Windows 10 users (i.e. 64% of Windows as a whole) are stuck five years behind in emoji versions, and even Windows 11 is still not showing flag emoji, which Microsoft excludes for some mysterious reason. (Geopolitical skittishness? Disregard for sports fans?)
  • Safari has the same fragmentation problem, with multiple users stuck on old versions of iOS, and thus old emoji fonts. For example, some 3.75% of iOS users are still on iOS 15, with only 2022-era emoji. [2]

As a result, every website on the planet that cares about having a consistent emoji experience has to bundle their own font or spritesheet, wasting untold megabytes for something that’s been standardized like clockwork by the Unicode Consortium for 15 years.

My recommendation remains the same: browsers should bundle their own emoji font and ship it outside of OS updates. Firefox does this right; they just need to switch to an up-to-date font like this Twemoji fork. There is an issue on Chromium to add the same functionality. As for Safari, well… they’re not quite evergreen, and fragmentation is just a consequence of that. But shipping a font is not rocket science, so maybe WebKit or iOS could be convinced to ship it out-of-band.

In the meantime, web developers can use a COLR font or polyfill to get a reasonably consistent experience across browsers. It’s just sad to me that, with all the stunning advancements browsers have made in recent years, and with all the overwhelming popularity of emoji, the web still struggles at rendering them.

Footnotes1. I’m using Codepen for this because WordPress automatically replaces native emoji with s, since of course browsers can’t be trusted to render a font properly. Although ironically, they render the old flag (on certain browsers anyway): 2. For posterity: using Wikimedia’s stats for August 12th through September 16th 2024: 1.2% mobile Safari 15 users / 32% mobile Safari users = 3.75%.

View Details

Writing good benchmarks is hard. Even if you grasp the basics of performance timings and measurements, it’s easy to fool yourself:

  • You weren’t measuring what you thought you were measuring.
  • You got the answer you wanted, so you stopped looking.
  • You didn’t clean state between tests, so you were just measuring the cache.
  • You didn’t understand how JavaScript engines work, so it optimized away your code.

Et cetera, et cetera. Anyone who’s been doing web performance long enough probably has a story about how they wrote a benchmark that gave them some satisfying result, only to realize later that they flubbed some tiny detail that invalidated the whole thing. It can be crushing.

For years now, though, I’ve been using Tachometer for most browser-based benchmarks. It’s featured in this blog a few times, although I’ve never written specifically about it.

Tachometer doesn’t make benchmarking totally foolproof, but it does automate a lot of the trickiest bits. What I like best is that it:

  1. Runs iterations until it reaches statistical significance.
  2. Alternates between two scenarios in an interleaved fashion.
  3. Launches a fresh browser profile between each iteration.

To be concrete, let’s say you have two scenarios you want to test: A and B. For Tachometer, these can simply be two web pages:

```

```

```

``` The only requirement is that you have something to measure, e.g. a performance measure:

function scenarioA() { // or B performance.mark('start') doStuff() performance.measure('total', 'start')} Now let’s say you want to know whether scenarioA or scenarioB is faster. Tachometer will essentially do the following:

  1. Load a.html.
  2. Load b.html.
  3. Repeat steps 1-2 until reaching statistical confidence that A and B are different enough (e.g. 1% different, 10% different, etc.).

This has several nice properties:

  1. Environment-specific variance is removed. Since you’re running A and B at the same time, on the same machine, in an interleaved fashion, it’s very unlikely that some environmental quirk will cause A to be artificially different from B.
  2. You don’t have to guess how many iterations are “enough” – the statistical test does that for you.
  3. The browser is fresh between each iteration, so you’re not just measuring cached/JITed performance.

That said, there are several downsides to this approach:

  1. The less different A and B are, the longer the tool will take to tell you that there’s no difference. In a CI environment, this is basically a nonstarter, because 99% of PRs don’t affect performance, and you don’t want to spend hours of CI time just to find out that updating the README didn’t regress anything.
  2. As mentioned, you’re not measuring JITed time. Sometimes you want to measure that, though. In that case, you have to run your own iterations-within-iterations (e.g. a for-loop) to avoid measuring the pre-JITed time.
  3. …Which you may end up doing anyway, because the tool tends to work best when your iterations take a large enough chunk of time. In my experience, a minimum of 50ms is preferable, although throttling can help you get there.

Tachometer also lacks any kind of visualization of performance changes over time, although there is a good GitHub action that can report the difference between a PR and your main branch. (Although again, you might not want to run it on every PR.)

The way I typically use Tachometer is to run one-off benchmarks, for instance when I’m doing some kind of cross-browser performance analysis. I often generate the config files rather than hand-authoring them, since they can be a bit repetitive.

Also, I only use Tachometer for browser or JavaScript tests – for anything else, I’d probably look into Hyperfine. (Haven’t used it, but heard good things about it.)

Tachometer has served me well for a long time, and I’m a big fan. The main benefit I’ve found is just the consistency of its results. I’ve been surprised how many times it’s consistently reported some odd regression (even in the ~1% range), which I can then track down with a git bisect to find the culprit. It’s also great for validating (or debunking) proposed perf optimizations.

Like any tool, Tachometer definitely has its flaws, and it’s still possible to fool yourself with it. But until I find a better tool, this is my go-to for low-level JavaScript microbenchmarks.

Bonus tip: running Tachometer in --manual mode with the DevTools performance tab is a great way to validate that you’re measuring what you think you’re measuring. Pay attention to the “User Timings” section to ensure your timings line up with what you expect.

View Details

One question I see a lot about web components is whether this is okay:

async connectedCallback() { const response = await fetch('/data.json') const data = await response.json() this.\_data = data} The answer is: yes. It’s fine. Go ahead and make your connectedCallbacks async. Thanks for reading.

What? You want a longer answer? Most people would have tabbed over to Reddit by now, but sure, no problem.

The important thing to remember here is that an async function is just a function that returns a Promise. So the above is equivalent to:

connectedCallback() { return fetch('/data.json') .then(response => response.json()) .then(data => { this.\_data = data })} Note, though, that the browser expects connectedCallback to return undefined (or void for you TypeScript-heads). The same goes for disconnectedCallback, attributeChangedCallback, and friends. So the browser is just going to ignore whatever you return from those functions.

The best argument against using this pattern is that it kinda-sorta looks like the browser is going to treat your async function differently. Which it definitely won’t.

For example, let’s say you have some setup/teardown logic:

async connectedCallback() { await doSomething() window.addEventListener('resize', this.\_onResize)}async disconnectedCallback() { await undoSomething() window.removeEventListener('resize', this.\_onResize)} You might naïvely think that the browser is going to run these callbacks in order, e.g. if the component is quickly connected and disconnected:

div.appendChild(component)div.removeChild(component) However, it will actually run the callbacks synchronously, with any async operations as a side effect. So in the above example, the awaits might resolve in a random order, causing a memory leak due to the dangling resize listener.

So as an alternative, you might want to avoid async connectedCallback just to make it crystal-clear that you’re using side effects:

connectedCallback() { (async () => { const response = await fetch('/data.json') const data = await response.json() this.\_data = data })()} To me, though, this is ugly enough that I’ll just stick with async connectedCallback. And if I really need my async functions to execute sequentially, I might use the sequential Promise pattern or something.

View Details

I think filing bugs on browsers is one of the most useful things a web developer can do.

When faced with a cross-browser compatibility problem, a lot of us are conditioned to just search for some quick workaround, or to keep cycling through alternatives until something works. And this is definitely what I did earlier in my career. But I think it’s too short-sighted.

Browser dev teams are just like web dev teams – they have priorities and backlogs, and they sometimes let bugs slip through. Also, a well-written bug report with clear steps-to-repro can often lead to a quick resolution – especially if you manage to nerd-snipe some bored or curious engineer.

As such, I’ve filed a lot of bugs on browsers over the years. For whatever reason – stubbornness, frustration, some highfalutin sense of serving the web at large – I’ve made a habit of nagging browser vendors about whatever roadblock I’m hitting that day. And they often fix it!

So I thought it might be interesting to do an analysis of the bugs I’ve filed on the major browser engines – Chromium, Firefox, and WebKit – over my roughly 10-year web development career. I’ve excluded older and lesser-known browser engines that I never filed bugs on, and I’ve also excluded Trident/EdgeHTML, since the original “Microsoft Connect” bug tracker seems to be offline. (Also, I was literally paid to file bugs on EdgeHTML for a good 2 years, so it’s kind of unfair.)

Some notes about this data set, before people start drawing conclusions:

  • Chromium is a bit over-represented, because I tend to use Chromedriver-related tools (e.g. Puppeteer) a lot more than other browser automation tools.
  • WebKit is kind of odd in that a lot of these bugs turned out to be in proprietary Apple systems (Safari, iOS, etc.) rather than WebKit proper. (At least, this is what I assume the enigmatic rdar:// response means. [1])
  • I excluded one bug from Firefox that was actually on MDN (which uses the same bug tracker).

The dataSo without further ado, here is the data set:

| Browser | Filed | Open | Fixed | Invalid | Fixed% | | --- | --- | --- | --- | --- | --- | | Chromium | 27 | 4 | 14 | 9 | 77.78% | | Firefox | 18 | 3 | 8 | 7 | 72.73% | | WebKit | 25 | 6 | 12 | 7 | 66.67% | | Total | 70 | 13 | 34 | 23 | 72.34% |

Notes: For “Invalid,” I’m being generous and including “duplicate,” “obsolete,” “wontfix,” etc. For “Fixed%,” I’m counting only the number fixed as a proportion of valid bugs.

Some things that jump out at me from the data set:

  • The “Fixed%” is pretty similar for all three browsers, although WebKit’s is a bit lower. When I look at the unfixed WebKit bugs, 2 are related to iOS rather than WebKit, one is in WebSQL (RIP), and the remaining 3 are honestly pretty minor. So I can’t really blame the WebKit team. (And one of those minor issues wound up in Interop 2024, so it may get fixed soon.)
  • For the 3 open Firefox issues, 2 of them are quite old and have terrible steps-to-repro (mea culpa), and the remaining one is a minor CSS issue related to shadow DOM.
  • For the 4 open Chromium issues, one of them is actually obsolete (I pinged the thread), 2 are quite minor, and the remaining one is partially fixed (it works when BFCache is enabled).
  • I was surprised that the total number of bugs filed on Firefox wasn’t even lower. My hazy memory was that I had barely filed any bugs on Firefox, and when I did, it usually turned out that they were following the spec but the other browsers weren’t. (I learned to really triple-check my work before filing bugs on them!)
  • 6 of the bugs I filed on WebKit were for IndexedDB, which definitely matches my memory of hounding them with bug reports for IDB. (In comparison, I filed 3 IDB bugs on Chromium and 0 on Firefox.)
  • As expected, 5 issues I filed on Chromium were due to ChromeDriver, DevTools, etc.

If you’d like to peruse the raw data, it can be found below:

Chromium data

| Status | ID | Title | Date | | --- | --- | --- | --- | | Fixed | 41495645 | Chromium leaks Document/Window/etc. when navigating in multi-page site using CDP-based heapsnapshots | Feb 22, 2024 01:02AM | | New | 40890306 | Reflected ARIA properties should not treat setting undefined the same as null | Mar 2, 2024 05:50PM | | New | 40885158 | Setting outerHTML on child of DocumentFragment throws error | Jan 8, 2024 10:52PM | | Fixed | 40872282 | aria-label on a should be ignored for accessible name calculation | Jan 8, 2024 11:24PM | | Duplicate | 40229331 | Style calculation takes much longer for multiple s vs one big | Jun 20, 2023 09:14AM | | Fixed | 40846966 | customElements.whenDefined() resolves with undefined instead of constructor | Jan 8, 2024 10:13PM | | Fixed | 40827056 | ariaInvalid property not reflected to/from aria-invalid attribute | Jan 8, 2024 08:33PM | | Obsolete | 40767620 | Heap snapshot includes objects referenced by DevTools console | Jan 8, 2024 11:53PM | | New | 40766136 | Restoring selection ranges causes text input to ignore keypresses | Jan 8, 2024 07:20PM | | Fixed | 40759641 | Poor style calculation performance for attribute selectors compared to class selectors | May 5, 2021 01:40PM | | Obsolete | 40149430 | performance.measureMemory() disallowed in headless mode | Feb 27, 2023 12:26AM | | Obsolete | 40704787 | Add option to disable WeakMap keys and circular references in Retainers graph | Jan 8, 2024 05:52PM | | Fixed | 40693859 | Chrome crashes due to WASM file when DevTools are recording trace | Jun 24, 2020 07:20AM | | Fixed | 40677812 | npm install chrome-devtools-frontend fails due to preinstall script | Jan 8, 2024 05:17PM | | New | 40656738 | Navigating back does not restore focus to clicked element | Jan 8, 2024 03:26PM | | Obsolete | 41477958 | Compositor animations recalc style on main thread every frame with empty requestAnimationFrame | Aug 29, 2019 04:06AM | | Duplicate | 41476815 | OffscreenCanvas convertToBlob() is >300ms slower than | Feb 18, 2020 11:51PM | | Fixed | 41475186 | Insertion and removal of overflow:scroll element causes large style calculation regression | Aug 26, 2019 01:34AM | | Obsolete | 41354172 | IntersectionObserver uses root’s padding box rather than border box | Nov 12, 2018 09:43AM | | Obsolete | 41329253 | word-wrap:break-word with odd Unicode characters causes long layout | Jul 11, 2017 01:50PM | | Fixed | 41327511 | IntersectionObserver boundingClientRect has inaccurate width/height | Jun 1, 2019 08:28PM | | Fixed | 41267419 | Chrome 52 sends a CORS preflight request with an empty Access-Control-Request-Headers when all author headers are CORS-safelisted | Mar 18, 2017 02:27PM | | Fixed | 41204713 | IndexedDB blocks DOM rendering | Jan 24, 2018 04:03PM | | Fixed | 41189720 | chrome://inspect/#devices flashing “Pending authorization” for Android device | Oct 5, 2015 03:59AM | | Obsolete | 41154786 | Chrome for iOS: openDatabase causes DOM Exception 11 or 18 | Feb 9, 2015 08:30AM | | Fixed | 41151574 | Empty IndexedDB blob causes 404 when fetched with ajax | Mar 16, 2015 11:37AM | | Fixed | 40400696 | Blob stored in IndexedDB causes null result from FileReader | Feb 9, 2015 10:34AM |

Firefox data

| ID | Summary | Resolution | Updated | | --- | --- | --- | --- | | 1704551 | Poor style calculation performance for attribute selectors compared to class selectors | FIXE | 2021-09-02 | | 1861201 | Support ariaBrailleLabel and ariaBrailleRoleDescription reflection | FIXE | 2024-02-20 | | 1762999 | Intervening divs with ids reports incorrect listbox options count to NVDA | FIXE | 2023-10-10 | | 1739154 | delegatesFocus changes focused inner element when host is focused | FIXE | 2022-02-08 | | 1707116 | Replacing shadow DOM style results in inconsistent computed style | FIXE | 2021-05-10 | | 1853209 | ARIA reflection should treat setting null/undefined as removing the attribute | FIXE | 2023-10-20 | | 1208840 | IndexedDB blocks DOM rendering | — | 2022-10-11 | | 1531511 | Service Worker fetch requests during ‘install’ phase block fetch requests from main thread | — | 2022-10-11 | | 1739682 | Bare ::part(foo) CSS selector selects parts inside shadow roots | — | 2024-02-20 | | 1331135 | Performance User Timing entry buffer restricted to 150 | DUPL | 2019-03-13 | | 1699154 | :focus-visible – JS-based focus() on back nav treated as keyboard input | FIXE | 2021-03-19 | | 1449770 | position:sticky inside of position:fixed does’t async-scroll in Firefox for Android (and asserts in ActiveScrolledRoot::PickDescendant() in debug build) | WORK | 2023-02-23 | | 1287221 | WebDriver:Navigate results in slower performance.timing metrics | DUPL | 2023-02-09 | | 1536717 | document.scrollingElement.scrollTop is incorrect | DUPL | 2022-01-10 | | 1253387 | Safari does not support IndexedDB in a worker | FIXE | 2016-03-17 | | 1062368 | Ajax requests for blob URLs return 0 as .status even if the load succeeds | DUPL | 2014-09-04 | | 1081668 | Blob URL returns xhr.status of 0 | DUPL | 2015-02-25 | | 1711057 | :focus-visible does not match for programmatic keyboard focus after mouse click | FIXE | 2021-06-09 | | 1471297 | fetch() and importScripts() do not share HTTP cache | WORK | 2021-03-17 |

WebKit data

| ID | Resolution | Summary | Changed | | --- | --- | --- | --- | | 225723 | — | Restoring selection ranges causes text input to ignore keypresses | 2023-02-26 | | 241704 | — | Preparser does not download stylesheets before running inline scripts | 2022-06-23 | | 263663 | — | Support ariaBrailleLabel and ariaBrailleRoleDescription reflection | 2023-11-01 | | 260716 | FIXE | adoptedStyleSheets (ObservableArray) has non-writable length | 2023-09-03 | | 232261 | FIXE | :host::part(foo) selector does not select elements inside shadow roots | 2021-11-04 | | 249420 | DUPL | :host(.foo, .bar) should be an invalid selector | 2023-08-07 | | 249737 | FIXE | Setting outerHTML on child of DocumentFragment throws error | 2023-07-15 | | 251383 | INVA | Reflected ARIA properties should not treat setting undefined the same as null | 2023-10-25 | | 137637 | — | Null character causes early string termination in Web SQL | 2015-04-25 | | 202655 | — | iOS Safari: timestamps can be identical for consecutive rAF callbacks | 2019-10-10 | | 249943 | — | Emoji character is horizontally misaligned when using COLR font | 2023-01-04 | | 136888 | FIXE | IndexedDB onupgradeneeded event has incorrect value for oldVersion | 2019-07-04 | | 137034 | FIXE | Completely remove all IDB properties/constructors when it is disabled at runtime | 2015-06-08 | | 149953 | FIXE | Modern IDB: WebWorker support | 2016-05-11 | | 151614 | FIXE | location.origin is undefined in a web worker | 2015-11-30 | | 156048 | FIXE | We sometimes fail to remove outdated entry from the disk cache after revalidation and when the resource is no longer cacheable | 2016-04-05 | | 137647 | FIXE | Fetching Blob URLs with XHR gives null content-type and content-length | 2017-06-07 | | 137756 | INVA | WKWebView: JavaScript fails to load, apparently due to decoding error | 2014-10-20 | | 137760 | DUPL | WKWebView: openDatabase results in DOM Exception 18 | 2016-04-27 | | 144875 | INVA | WKWebView does not persist IndexedDB data after app close | 2015-05-28 | | 149107 | FIXE | IndexedDB does not throw ConstraintErrors for unique keys | 2016-03-21 | | 149205 | FIXE | IndexedDB openKeyCursor() returns primaryKeys in wrong order | 2016-03-30 | | 149585 | DUPL | Heavy LocalStorage use can cause page to freeze | 2016-12-14 | | 156125 | INVA | Fetching blob URLs with query parameters results in 404 | 2022-05-31 | | 169851 | FIXE | Safari sends empty “Access-Control-Request-Headers” in preflight request | 2017-03-22 |

ConclusionI think cross-browser compatibility has improved a lot over the past few years. We have projects like Interop and Web Platform Tests, which make it a lot more streamlined for browser teams to figure out what’s broken and what they should prioritize.

So if you haven’t yet, there’s no better time to get started filing bugs on browsers! I’d recommend first searching for your issue in the right bug tracker (Chromium, Firefox, WebKit), then creating a minimal repro (CodePen, JSBin, plain HTML, etc.), and finally just including as much detail as you can (browser version, OS version, screenshots, etc.). I’d also recommend reading “How to file a good browser bug”.

Happy bug hunting!

Footnotes1. Some folks have pointed out to me that rdar:// links can mean just about anything. I always assumed it meant that the bug got re-routed to some internal team, but I guess not.

View Details

A common mistake I see in web components is this:

class MyComponent extends HTMLElement { constructor() { super() setupLogic() } disconnectedCallback() { teardownLogic() }} This setupLogic() can be just about anything – subscribing to a store, setting up event listeners, etc. The teardownLogic() is designed to undo those things – unsubscribe from a store, remove event listeners, etc.

The problem is that constructor is called once, when the component is created. Whereas disconnectedCallback can be called multiple times, whenever the element is removed from the DOM.

The correct solution is to use connectedCallback instead of constructor:

class MyComponent extends HTMLElement { connectedCallback() { setupLogic() } disconnectedCallback() { teardownLogic() }} Unfortunately it’s really easy to mess this up and to not realize that you’ve done anything wrong. A lot of the time, a component is created once, inserted once, and removed once. So the difference between constructor and connectedCallback never reveals itself.

However, as soon as your consumer starts doing something complicated with your component, the problem rears its ugly head:

const component = new MyComponent() // constructordocument.body.appendChild(component) // connectedCallbackdocument.body.removeChild(component) // disconnectedCallbackdocument.body.appendChild(component) // connectedCallback again!document.body.removeChild(component) // disconnectedCallback again! This can be really subtle. A JavaScript framework’s diffing algorithm might remove an element from a list and insert it into a different position in the list. If so: congratulations! You’ve been disconnected and reconnected.

Or you might call appendChild() on an element that’s already appended somewhere else. Technically, the DOM considers this a disconnect and a reconnect:

// Calls connectedCallbackcontainerOne.appendChild(component)// Calls disconnectedCallback and connectedCallbackcontainerTwo.appendChild(component) The bottom line is: if you’re doing something in disconnectedCallback, you should do the mirror logic in connectedCallback. If not, then it’s a subtle bug just lying in wait for the right moment to strike.

Note: See also “You’re (probably) using connectedCallback wrong” by Hawk Ticehurst, which offers similar advice.

View Details

Compared to previous years, my reading velocity has taken a bit of a nosedive. Blame videogames, maybe: I’ve put more hours into Civilization 6 than I care to admit, and I’m currently battling Moblins and Bokoblins in Zelda: Tears of the Kingdom.

I’ve also been trying to re-learn the guitar. I basically stopped playing for nearly a decade, but this year I was surprised to learn that it’s a lot like riding a bike: my fingers seem to know things that my brain thought I had forgotten. I’ve caught up on most of the songs I used to know, and I’m looking forward to learning more in 2024.

(The wonderful gametabs.net used to be my go-to source for great finger-picking-style videogame songs, but like a lot of relics of the old internet, its future seems to be in doubt. I may have to find something else.)

In any case! Here are the books:

Quick linksFiction A Wizard of Earthsea by Ursula K. Le Guin * 1Q84 by Haruki Murakami * Dare to Know by James Kennedy * Cloud Atlas* by David Mitchell

Non-fiction The Intelligence Illusion by Baldur Bjarnason * Pure Invention: How Japan Made the Modern World by Matt Alt * Creative Selection: Inside Apple’s Design Process During the Golden Age of Steve Jobs by Ken Kocienda * Fifty Plants that Changed the Course of History* by Bill Laws

FictionA Wizard of Earthsea by Ursula K. Le GuinOne of those classics of fantasy literature that I had never gotten around to reading. I really enjoyed this one, especially as it gave me a new appreciation for Patrick Rothfuss’s The Name of the Wind, which seems to draw heavily from the themes of Earthsea – in particular, the idea that knowing the “true name” of something gives you power over it. I gave the second Earthsea book a shot, but haven’t gotten deep enough to get drawn in yet.

1Q84 by Haruki MurakamiI’ve always enjoyed Murakami’s dreamy, David Lynch-like magic realism. This one runs a little bit too long for my taste – it starts off strong and starts to drag near the end – but I still thoroughly enjoyed it.

Dare to Know by James KennedyThis one was a bit of a sleeper hit which I was surprised to find wasn’t more popular. It’s a high-concept sci-fi / dystopian novel with a lot of fun mysteries and twists. I would be completely unsurprised if it gets turned into a Christopher Nolan movie in a few years.

Cloud Atlas by David MitchellThis book has a big reputation, which I think is thoroughly earned. It’s best to read it without knowing anything about what it’s about, so that you can really experience the whole voyage the author is trying to take you on here.

All I’ll say is that if you like sci-fi and aren’t intimidated by weird or archaic language, then this book is for you.

Non-fictionThe Intelligence Illusion by Baldur BjarnasonLike Out of the Software Crisis last year, this book had a big impact on me. This book deepened my skepticism about the current wave of GenAI hype, although I do admit (like the author) that it still has some reasonable use cases.

Unfortunately I think a lot of people are jumping into the GenAI frenzy without reading sober analyses like these, so we’ll probably have to learn the hard way what the technology is good at and what it’s terrible at.

Pure Invention: How Japan Made the Modern World by Matt AltAs a certified Japanophile nerd (I did admit I play videogame music, right?), this book was a fun read for me. It’s especially interesting to see Japan’s cultural exports (videogames, manga, etc.) from the perspective of their own home country. I admit I hadn’t thought much about how things like Gundam or Pokémon were perceived by fans back home, so this book gave me a better context for the artifacts that shaped my childhood.

Creative Selection: Inside Apple’s Design Process During the Golden Age of Steve Jobs by Ken KociendaYes, there is some hero-worship of Steve Jobs here, but there is also just a really engrossing story of great engineers doing great work at the right place in the right time. I especially loved the bits about how the original iPhone soft keyboard was designed, and how WebKit was initially chosen as the browser engine for Safari.

Fifty Plants that Changed the Course of History by Bill LawsI’ve always been one of those pedants who loves to point out that most staples of European cuisine (pizza in Italy, fish and chips in Britain) are really foreign imports, since things like tomatoes and potatoes are New World plants. So this book was perfect for me. It’s also a fun read since it’s full of great illustrations, and gives just the right amount of detail – only the barest overview of how the plants were discovered, how they were popularized, and how they’re used today.

View Details

Web components are kind of having a moment right now. And as part of that, shadow DOM is having a bit of a moment too. Or it would, except that much of the conversation seems to be about why you shouldn’t use shadow DOM.

For example, “HTML web components” are based on the idea that you should use most of the goodness of web components (custom elements, lifecycle hooks, etc.), while dropping shadow DOM like a bad habit. (Another name for this is “light DOM components.”)

This is a perfectly fine pattern for certain cases. But I also think some folks are confused about the tradeoffs with shadow DOM, because they don’t understand what shadow DOM is supposed to accomplish in the first place. In this post, I’d like to clear up some of the misconceptions by explaining what shadow DOM is supposed to do, while also weighing its success in actually achieving it.

What the heck is shadow DOM forThe main goal of shadow DOM is encapsulation. Encapsulation is a tricky concept to explain, because the benefits are not immediately obvious.

Let’s say you have a third-party component that you’ve decided to include on your website or webapp. Maybe you found it on npm, and it solved some use case very nicely. Let’s say it’s something simple, like a dropdown component.

You know what, though? You really don’t like that caret character – you’d rather have a emoji. And you’d really prefer rounded corners. And the theme color should be red instead of blue. So you hack together some CSS:

.dropdown { background: red; border-radius: 8px;}.dropdown .caret::before { content: '';} Great! You get the styling you want. Ship it.

Except that 6 months later, the component has an update. And it’s to fix a security vulnerability! Your boss is pressuring you to update the component as fast as possible, since otherwise the website won’t pass a security audit anymore. So you go to update, and…

Everything’s broken.

It turns out that the component changed their internal class name from dropdown to picklist. And they don’t use CSS content for the caret anymore. And they added a wrapper <div>, so the border-radius needs to be applied to something else now. Suddenly you’re in for a world of hurt, just to get the component back to the way it used to look.

Global control is great until it isn’tCSS gives you an amazing superpower, which is that you can target any element on the page as long as you can think of the right selector. It’s incredibly easy to do this in DevTools today – a lot of people are trained to right-click, “Inspect Element,” and rummage around for any class or attribute to start targeting the element. And this works great in the short term, but it affects the long-term maintainability of the code, especially for components you don’t own.

This isn’t just a problem with CSS – JavaScript has this same flaw due to the DOM. Using document.querySelector (or equivalent APIs), you can traverse anywhere you want in the DOM, find an element, and apply some custom behavior to it – e.g. adding an event listener or changing its internal structure. I could tell the same story above using JavaScript rather than CSS.

This openness can cause headaches for component authors as well as component consumers. In a system where the onus is on the component author to ship new versions (e.g. a monorepo, a platform, or even just a large codebase), component authors can effectively get frozen in time, unable to ship any internal refactors for fear of breaking their downstream consumers.

Shadow DOM attempts to solve these problems by providing encapsulation. If the third-party dropdown component were using shadow DOM, then you wouldn’t be able to target arbitrary content inside of it (except with elaborate workarounds that I don’t want to get into).

Of course, by closing off access to global styling and DOM traversal, shadow DOM also greatly limits a component’s customizability. Consumers can’t just decide they want a background to be red, or a border to be rounded – the component author has to provide an explicit styling API, using tools like CSS custom properties or parts. E.g.:

snazzy-dropdown { --dropdown-bg: red;}snazzy-dropdown::part(caret)::before { content: '';} By exposing an explicit styling API, the risk of breakage across component upgrades is heavily reduced. The component author is effectively declaring an API surface that they intend to support, which limits what they need to keep stable over time. (This API can still break, as with a major version bump, but that’s another story.)

TradeoffsWhen people complain about shadow DOM, they seem to mostly be complaining about style encapsulation. They want to reach in and add a rounded corner on some component, and roll the dice that the component doesn’t change in the future. Depending on what kind of website you’re building, this can be a perfectly acceptable tradeoff. For example:

  • A portfolio site
  • A news article with interactive charts
  • A marketing site for a Super Bowl campaign
  • A landing page that will be rewritten in 2 years anyway

In all of these cases, long-term maintenance is not really a big concern. The page either has a limited shelf life, or it’s just not important to keep its dependencies up to date. So if the dropdown component breaks in a year or two, nobody cares.

Of course, there is also the opposite world where long-term maintenance matters a lot:

  • An interactive productivity app
  • A design system
  • A platform with its own app store for UI components
  • An online multiplayer game

I could go on, but the point is: the second group cares a lot more about long-term maintainability than the first group. If you’ve spent your entire career working on the first group, then you may indeed find shadow DOM to be baffling. You can’t possibly understand why you should be prevented from globally styling whatever you want.

Conversely, if you’ve spent your entire career in the second group, then you may be equally baffled by people who want global access to everything. (“Are they trying to shoot themselves in the foot?”) This is why I think people are often talking past each other about this stuff.

But does it workSo now that we’ve established the problem shadow DOM is trying to solve, there’s the inevitable question: does it actually solve it?

This is an important question, because I think it’s the source of the other major tension with shadow DOM. Even people who understand the problem are not in agreement that shadow DOM actually solves it.

If you want to get a good sense of people’s frustrations with shadow DOM, there are two massive GitHub threads you can check out:

  • Web components should be able to easily adapt to the host page while maintaining encapsulation by Lea Verou
  • “Open-stylable” shadow roots by Justin Fagnani

There are a lot of potential solutions being tossed around in those threads (including by me), but I’m not really convinced that any one of them is the silver bullet that is going to solve people’s frustrations with shadow DOM. And the reason is that the core problem here is a coordination problem, not a technical problem.

For example, take “open-stylable shadow roots.” The idea is that a shadow root can inherit the styles from its parent context (exactly like light DOM). But then of course, we get into the coordination problem:

  • Will every web component on npm need to enable open-stylable shadow roots?
  • Or will page authors need a global mechanism to force every component into this mode?
  • What if a component author doesn’t want to be opted-in? What if they prefer the lower maintenance costs of a small API surface?

There’s no right answer here. And that’s because there’s an inherent conflict between the needs of the component author and the page author. The component author wants minimal maintenance costs and to avoid breaking their downstream consumers with every update, and the page author wants to style every component on the page to pixel-perfect precision, while also never being broken.

Stated that way, it sounds like an unsolvable problem. In practice, I think the problem gets solved by favoring one group over the other, which can make some sense depending on the context (largely based on whether your website is in group one or group two above).

A potential solution?If there is one solution I find promising, it’s articulated by my colleague Caridy Patiño:

Build building blocks that encapsulate logic and UI elements that are “fully” customizable by using existing mechanisms (CSS properties, parts, slots, etc.). Everything must be customizable from outside the shadow.

If a building block is using another building block in its shadow, it must do it as part of the default content of a well-defined slot.

Essentially, what Caridy is saying is that instead of providing a dropdown component to be used like this:

<snazzy-dropdown></snazzy-dropdown> … you instead provide one like this:

<snazzy-dropdown> <snazzy-trigger> <button>Click ▼</button> </snazzy-trigger> <snazzy-listbox> <snazzy-option>One</snazzy-option> <snazzy-option>Two</snazzy-option> <snazzy-option>Three</snazzy-option> </snazzy-listbox></snazzy-dropdown> In other words, the component should expose its “guts” externally (using <slot>s in this example) so that everything is stylable. This way, anything the consumer may want to customize is fully exposed to light DOM.

This is not a totally new idea. In fact, outside of the world of web components, plenty of component systems have run into similar problems and arrived at similar solutions. For example, so-called “headless” component systems (such as Radix UI, Headless UI, and Tanstack) have embraced this kind of design.

For comparison, here is an (abridged) example of the dropdown menu from the Radix docs:

<DropdownMenu.Root> <DropdownMenu.Trigger> <Button variant="soft"> Options <CaretDownIcon /> </Button> </DropdownMenu.Trigger> <DropdownMenu.Content> <DropdownMenu.Item shortcut="⌘ E">Edit</DropdownMenu.Item> <DropdownMenu.Item shortcut="⌘ D">Duplicate</DropdownMenu.Item> <!-- ... ---> <DropdownMenu.Content><DropdownMenu.Root> This is pretty similar to the web component sketch above – the “guts” of the dropdown are on display for all to see, and anything in the UI is fully customizable.

To me, though, these solutions are clearly taking the burden of complexity and shifting it from the component author to the component consumer. Rather than starting with the simplest case and providing a bare-bones default, the component author is instead starting with the complex case, forcing the consumer to (likely) copy-paste a lot of boilerplate into their codebase before they can start tweaking.

Now, maybe this is the right solution! And maybe the long-term maintenance costs are worth it! But I think the tradeoff should still be acknowledged.

As I understand it, though, these kinds of “headless” solutions are still a bit novel, so we haven’t gotten a lot of real-world data to prove the long-term benefits. I have no doubt, though, that a lot of component authors see this approach as the necessary remedy to the problem of runaway configurability – i.e. component consumers ask for every little thing to be configurable, all those configuration options get shoved into one top-level API, and the overall experience starts to look like recursive Swiss Army Knives. (Tanner Linsley gives a great talk about this, reflecting on 5 years of building React Table.)

Personally, I’m intrigued by this technique, but I’m not fully convinced that exposing the “guts” of a component really reduces the overall maintenance cost. It’s kind of like, instead of selling a car with a predefined set of customizations (color, window tint, automatic vs manual, etc.), you’re selling a loose set of parts that the customer can mix-and-match into whatever kind of vehicle they want. Rather than a car off the assembly line, it reminds me of a jerry-rigged contraption from Minecraft or Zelda.

In Tears of the Kingdom, you can glue together just about anything, and it will kind of work.

I haven’t worked on such a component system, but I’d worry that you’d get bugs along the lines of, “Well, when I put the slider on the left it works, but when I put it on the right, the scroll position gets messed up.” There is so much potential customizability, that I’m not sure how you could even write tests to cover all the possible configurations. Although maybe that’s the point – there’s effectively no UI, so if the UI is messed up, then it’s the component consumer’s job to fix it.

ConclusionI don’t have all the answers. At this point, I just want to make sure we’re asking the right questions.

To me, any proposed solution to the current problems with shadow DOM should be prefaced with:

  • What kind of website or webapp is the intended context?
  • Who stands to benefit from this change – the component author or page author?
  • Who needs to shift their behavior to make the whole thing work?

I’m also not convinced that any of this stuff is ripe enough for the standards discussion to begin. There are so many options that can be explored in userland right now (e.g. the “expose the guts” proposal, or a polyfill for open-stylable shadow roots), that it’s premature to start asking standards bodies to standardize anything.

I also think that the inherent conflict between the needs of component authors and component consumers has not really been acknowledged enough in the standards discussions. And the W3C’s priority of constituencies doesn’t help us much here:

User needs come before the needs of web page authors, which come before the needs of user agent implementors, which come before the needs of specification writers, which come before theoretical purity.

In the above formulation, there’s no distinction between component authors and component consumers – they are both just “web page authors.” I suppose conceptually, if we imagine the whole web platform as a “stack,” then we would place the needs of component consumers over component authors. But even that gets muddy sometimes, since component authors and component consumers can work on the same team or even be the same person.

Overall, what I would love to see is a thorough synopsis of the various groups involved in the web component ecosystem, how the existing solutions have worked in practice, what’s been tried and what hasn’t, and what needs to change to move forward. (This blog post is not it; this is just my feeble groping for a vocabulary to even start talking about the problem.)

In my mind, we are still chasing the holy grail of true component reusability. I often think back to this eloquent talk by Jan Miksovsky, where he explains how much has been standardized in the world of building construction (e.g. the size of windows and door frames), whereas us web developers are still stuck rebuilding the same thing over and over again. I don’t know if we’ll ever reach true component reusability (or if building construction is really as rosy as he describes – I can barely wield a hammer), but I do know that I still find the vision inspiring.

View Details

In my last post, we went on a guided tour of building a JavaScript framework from scratch. This wasn’t just an intellectual exercise, though – I actually had a reason for wanting to build my own framework.

For a few years now, I’ve been maintaining emoji-picker-element, which is designed as a fast, simple, and lightweight web component that you can drop onto any page that needs an emoji picker.

Most of the maintenance work has been about simply keeping pace with the regular updates to the Unicode standard to add new emoji versions. (Shout-out to Emojibase for providing a stable foundation to build on!) But I’ve also worked to keep up with the latest Svelte versions, since emoji-picker-element is based on Svelte.

The project was originally written in Svelte v3, and the v4 upgrade was nothing special. The v5 upgrade was only slightly more involved, which is astounding given that the framework was essentially rewritten from scratch. (How Rich and the team managed to pull this off boggles my mind.)

I should mention at this point that I think Svelte is a great framework, and a pleasure to work with. It’s probably my favorite JavaScript framework (other than the one I work on!). That said, a few things bugged me about the Svelte v5 upgrade:

  • It grew emoji-picker-element‘s bundle size by 7.1kB minified (it was originally a bit more, but Dominic Gannaway graciously made improvements to the tree-shaking).
  • It dropped support for older browsers due to syntax changes, in particular Safari 12 (which is 0.25-0.5% of browsers depending on who you ask).

Now, neither of these things really ought to be a dealbreaker. 7.1kB is not a huge amount for the average webapp, and an emoji picker should probably be lazy-loaded most of the time anyway. Also, Safari 12 might not be worth worrying about (and if it is, it won’t be in a couple years).

I also don’t think there’s anything wrong with building a standalone web component on top of a JavaScript framework – I’ve said so in the past. There are lots of fiddly bits that are hard to get right when you’re building a web component, and 99 times out of 100, you’re much better off using something like Svelte, or Lit, or Preact, or petite-vue, than to try to wing it yourself in Vanilla JS and building a half-baked framework in the process.

That said… I enjoy building a half-baked framework. And I have a bit of a competitive streak that makes me want to trim the bundle size as much as possible. So I decided to take this as an opportunity to rebuild emoji-picker-element on top of my own custom framework.

The end result is more or less what you saw in the previous post: a bit of reactivity, a dash of tagged template literals, and poof! A new framework is born.

This new framework is honestly only slightly more complex than what I sketched out in that post – I ended up only needing 85 lines of code for the reactivity engine and 233 for the templating system (as measured by cloc).

Of course, to get this minuscule size, I had to take some shortcuts. If this were an actual framework I was releasing to the world, I would need to handle a long tail of edge cases, perf hazards, and gnarly tradeoffs. But since this framework only needs to support one component, I can afford to cut some corners.

So does this tiny framework actually cut the mustard? Here are the results:

  • The bundle size is 6.1kB smaller than the current implementation (and ~13.2kB smaller than the Svelte 5 version).
  • Safari 12 is still supported (without needing code transforms).
  • There is no regression in runtime performance (as measured by Tachometer).
  • Initial memory usage is reduced by 140kB.

Here are the stats:

| Metric | Svelte v4 | Svelte v5 | Custom | | --- | --- | --- | --- | | Bundle size (min) | 42.6kB | 49.7kB | 36.5kB | | ↳ Delta | +7.1kB (+17%) | -6.1kB (-14%) | | Bundle size (min+gz) | 14.9kB | 18.8kB | 12.6kB | | ↳ Delta | +3.9kB (+26%) | -2.3kB (-15%) | | Initial memory usage | 1.23MB | 1.5MB | 1.09MB | | ↳ Delta | +270kB (+22%) | -140kB (-11%) |

Note: I’m not trying to say that Svelte 5 is bad, or that I’m smarter than the Svelte developers. As mentioned above, the only way I can get these fantastic numbers is by seriously cutting a lot of corners. And I actually really like the new features in Svelte v5 (snippets in particular are amazing, and the benchmark performance is truly impressive). I also can’t fault Svelte for focusing on their most important consumers, who are probably building entire apps out of Svelte components, and don’t care much about a higher baseline bundle size.

So was it worth it? I dunno. Maybe I will get a flood of bug reports after I ship this, and I will come crawling back to Svelte. Or maybe I will find that it’s too hard to add new features without the flexibility of a full framework. But I doubt it. I enjoyed building my own framework, and so I think I’ll keep it around just for the fun of it.

Side projects for me are always about three things: 1) learning, 2) sharing something with the world, and 3) having fun while doing so. emoji-picker-element ticks all three boxes for me, so I’m going to stick with the current design for the time being.

View Details

In my day job, I work on a JavaScript framework (LWC). And although I’ve been working on it for almost three years, I still feel like a dilettante. When I read about what’s going on in the larger framework world, I often feel overwhelmed by all the things I don’t know.

One of the best ways to learn how something works, though, is to build it yourself. And plus, we gotta keep those “days since last JavaScript framework” memes going. So let’s write our own modern JavaScript framework!

What is a “modern JavaScript framework”?React is a great framework, and I’m not here to dunk on it. But for the purposes of this post, “modern JavaScript framework” means “a framework from the post-React era” – i.e. Lit, Solid, Svelte, Vue, etc.

React has dominated the frontend landscape for so long that every newer framework has grown up in its shadow. These frameworks were all heavily inspired by React, but they’ve evolved away from it in surprisingly similar ways. And although React itself has continued innovating, I find that the post-React frameworks are more similar to each other than to React nowadays.

To keep things simple, I’m also going to avoid talking about server-first frameworks like Astro, Marko, and Qwik. These frameworks are excellent in their own way, but they come from a slightly different intellectual tradition compared to the client-focused frameworks. So for this post, let’s only talk about client-side rendering.

What sets modern frameworks apart?From my perspective, the post-React frameworks have all converged on the same foundational ideas:

  1. Using reactivity (e.g. signals) for DOM updates.
  2. Using cloned templates for DOM rendering.
  3. Using modern web APIs like <template> and Proxy, which make all of the above easier.

Now to be clear, these frameworks differ a lot at the micro level, and in how they handle things like web components, compilation, and user-facing APIs. Not all frameworks even use Proxys. But broadly speaking, most framework authors seem to agree on the above ideas, or they’re moving in that direction.

So for our own framework, let’s try to do the bare minimum to implement these ideas, starting with reactivity.

ReactivityIt’s often said that “React is not reactive”. What this means is that React has a more pull-based rather than a push-based model. To grossly oversimplify things: in the worst case, React assumes that your entire virtual DOM tree needs to be rebuilt from scratch, and the only way to prevent these updates is to implement React.memo (or in the old days, shouldComponentUpdate).

Using a virtual DOM mitigates some of the cost of the “blow everything away and start from scratch” strategy, but it doesn’t fully solve it. And asking developers to write the correct memo code is a losing battle. (See React Forget for an ongoing attempt to solve this.)

Instead, modern frameworks use a push-based reactive model. In this model, individual parts of the component tree subscribe to state updates and only update the DOM when the relevant state changes. This prioritizes a “performant by default” design in exchange for some upfront bookkeeping cost (especially in terms of memory) to keep track of which parts of the state are tied to which parts of the UI.

Note that this technique is not necessarily incompatible with the virtual DOM approach: tools like Preact Signals and Million show that you can have a hybrid system. This is useful if your goal is to keep your existing virtual DOM framework (e.g. React) but to selectively apply the push-based model for more performance-sensitive scenarios.

For this post, I’m not going to rehash the details of signals themselves, or subtler topics like fine-grained reactivity, but I am going to assume that we’ll use a reactive system.

Note: there are lots of nuances when talking about what qualifies as “reactive.” My goal here is to contrast React with the post-React frameworks, especially Solid, Svelte v5 in “runes” mode, and Vue Vapor.

Cloning DOM treesFor a long time, the collective wisdom in JavaScript frameworks was that the fastest way to render the DOM is to create and mount each DOM node individually. In other words, you use APIs like createElement, setAttribute, and textContent to build the DOM piece-by-piece:

const div = document.createElement('div')div.setAttribute('class', 'blue')div.textContent = 'Blue!' One alternative is to just shove a big ol’ HTML string into innerHTML and let the browser parse it for you:

const container = document.createElement('div')container.innerHTML = ` <div class="blue">Blue!</div>` This naïve approach has a big downside: if there is any dynamic content in your HTML (for instance, red instead of blue), then you would need to parse HTML strings over and over again. Plus, you are blowing away the DOM with every update, which would reset state such as the value of <input>s.

Note: using innerHTML also has security implications. But for the purposes of this post, let’s assume that the HTML content is trusted. 1

At some point, though, folks figured out that parsing the HTML once and then calling cloneNode(true) on the whole thing is pretty danged fast:

const template = document.createElement('template')template.innerHTML = ` <div class="blue">Blue!</div>`template.content.cloneNode(true) // this is fast! Here I’m using a <template> tag, which has the advantage of creating “inert” DOM. In other words, things like <img> or <video autoplay> don’t automatically start downloading anything.

How fast is this compared to manual DOM APIs? To demonstrate, here’s a small benchmark. Tachometer reports that the cloning technique is about 50% faster in Chrome, 15% faster in Firefox, and 10% faster in Safari. (This will vary based on DOM size and number of iterations, but you get the gist.)

What’s interesting is that <template> is a new-ish browser API, not available in IE11, and originally designed for web components. Somewhat ironically, this technique is now used in a variety of JavaScript frameworks, regardless of whether they use web components or not.

Note: for reference, here is the use of cloneNode on <template>s in Solid, Vue Vapor, and Svelte v5.

There is one major challenge with this technique, which is how to efficiently update dynamic content without blowing away DOM state. We’ll cover this later when we build our toy framework.

Modern JavaScript APIsWe’ve already encountered one new API that helps a lot, which is <template>. Another one that’s steadily gaining traction is Proxy, which can make building a reactivity system much simpler.

When we build our toy example, we’ll also use tagged template literals to create an API like this:

const dom = html` <div>Hello ${ name }!</div>` Not all frameworks use this tool, but notable ones include Lit, HyperHTML, and ArrowJS. Tagged template literals can make it much simpler to build ergonomic HTML templating APIs without needing a compiler.

Step 1: building reactivityReactivity is the foundation upon which we'll build the rest of the framework. Reactivity will define how state is managed, and how the DOM updates when state changes.

Let's start with some "dream code" to illustrate what we want:

const state = {}state.a = 1state.b = 2createEffect(() => { state.sum = state.a + state.b}) Basically, we want a “magic object” called state, with two props: a and b. And whenever those props change, we want to set sum to be the sum of the two.

Assuming we don’t know the props in advance (or have a compiler to determine them), a plain object will not suffice for this. So let’s use a Proxy, which can react whenever a new value is set:

const state = new Proxy({}, { get(obj, prop) { onGet(prop) return obj[prop] }, set(obj, prop, value) { obj[prop] = value onSet(prop, value) return true }}) Right now, our Proxy doesn’t do anything interesting, except give us some onGet and onSet hooks. So let’s make it flush updates after a microtask:

let queued = falsefunction onSet(prop, value) { if (!queued) { queued = true queueMicrotask(() => { queued = false flush() }) }} Note: if you’re not familiar with queueMicrotask, it’s a newer DOM API that’s basically the same as Promise.resolve().then(...), but with less typing.

Why flush updates? Mostly because we don’t want to run too many computations. If we update whenever both a and b change, then we’ll uselessly compute the sum twice. By coalescing the flush into a single microtask, we can be much more efficient.

Next, let’s make flush update the sum:

function flush() { state.sum = state.a + state.b} This is great, but it’s not yet our “dream code.” We’ll need to implement createEffect so that the sum is computed only when a and b change (and not when something else changes!).

To do this, let’s use an object to keep track of which effects need to be run for which props:

const propsToEffects = {} Next comes the crucial part! We need to make sure that our effects can subscribe to the right props. To do so, we’ll run the effect, note any get calls it makes, and create a mapping between the prop and the effect.

To break it down, remember our “dream code” is:

createEffect(() => { state.sum = state.a + state.b}) When this function runs, it calls two getters: state.a and state.b. These getters should trigger the reactive system to notice that the function relies on the two props.

To make this happen, we’ll start with a simple global to keep track of what the “current” effect is:

let currentEffect Then, the createEffect function will set this global before calling the function:

function createEffect(effect) { currentEffect = effect effect() currentEffect = undefined} The important thing here is that the effect is immediately invoked, with the global currentEffect being set in advance. This is how we can track whatever getters it might be calling.

Now, we can implement the onGet in our Proxy, which will set up the mapping between the global currentEffect and the property:

function onGet(prop) { const effects = propsToEffects[prop] ?? (propsToEffects[prop] = []) effects.push(currentEffect)} After this runs once, propsToEffects should look like this:

{ "a": [theEffect], "b": [theEffect]} …where theEffect is the “sum” function we want to run.

Next, our onSet should add any effects that need to be run to a dirtyEffects array:

const dirtyEffects = []function onSet(prop, value) { if (propsToEffects[prop]) { dirtyEffects.push(...propsToEffects[prop]) // ... }} At this point, we have all the pieces in place for flush to call all the dirtyEffects:

function flush() { while (dirtyEffects.length) { dirtyEffects.shift()() }} Putting it all together, we now have a fully functional reactivity system! You can play around with it yourself and try setting state.a and state.b in the DevTools console – the state.sum will update whenever either one changes.

Now, there are plenty of advanced cases that we’re not covering here:

  1. Using try/catch in case an effect throws an error
  2. Avoiding running the same effect twice
  3. Preventing infinite cycles
  4. Subscribing effects to new props on subsequent runs (e.g. if certain getters are only called in an if block)

However, this is more than enough for our toy example. Let’s move on to DOM rendering.

Step 2: DOM renderingWe now have a functional reactivity system, but it’s essentially “headless.” It can track changes and compute effects, but that’s about it.

At some point, though, our JavaScript framework needs to actually render some DOM to the screen. (That’s kind of the whole point.)

For this section, let’s forget about reactivity for a moment and imagine we’re just trying to build a function that can 1) build a DOM tree, and 2) update it efficiently.

Once again, let’s start off with some dream code:

function render(state) { return html` <div class="${state.color}">${state.text}</div> `} As I mentioned, I’m using tagged template literals, ala Lit, because I found them to be a nice way to write HTML templates without needing a compiler. (We’ll see in a moment why we might actually want a compiler instead.)

We’re re-using our state object from before, this time with a color and text property. Maybe the state is something like:

state.color = 'blue'state.text = 'Blue!' When we pass this state into render, it should return the DOM tree with the state applied:

```

Blue!

`` Before we go any further, though, we need a quick primer on tagged template literals. Ourhtmltag is just a function that receives two arguments: thetokens(array of static HTML strings) andexpressions` (the evaluated dynamic expressions):

function html(tokens, ...expressions) {} In this case, the tokens are (whitespace removed):

[ "<div class=\"", "\">", "</div>"] And the expressions are:

[ "blue", "Blue!"] The tokens array will always be exactly 1 longer than the expressions array, so we can trivially zip them up together:

const allTokens = tokens .map((token, i) => (expressions[i - 1] ?? '') + token) This will give us an array of strings:

[ "<div class=\"", "blue\">", "Blue!</div>"] We can join these strings together to make our HTML:

const htmlString = allTokens.join('') And then we can use innerHTML to parse it into a <template>:

function parseTemplate(htmlString) { const template = document.createElement('template') template.innerHTML = htmlString return template} This template contains our inert DOM (technically a DocumentFragment), which we can clone at will:

const cloned = template.content.cloneNode(true) Of course, parsing the full HTML whenever the html function is called would not be great for performance. Luckily, tagged template literals have a built-in feature that will help out a lot here.

For every unique usage of a tagged template literal, the tokens array is always the same whenever the function is called – in fact, it’s the exact same object!

For example, consider this case:

function sayHello(name) { return html`<div>Hello ${name}</div>`} Whenever sayHello is called, the tokens array will always be identical:

[ "<div>Hello ", "</div>"] The only time tokens will be different is for completely different locations of the tagged template:

html`<div></div>`html`<span></span>` // Different from above We can use this to our advantage by using a WeakMap to keep a mapping of the tokens array to the resulting template:

const tokensToTemplate = new WeakMap()function html(tokens, ...expressions) { let template = tokensToTemplate.get(tokens) if (!template) { // ... template = parseTemplate(htmlString) tokensToTemplate.set(tokens, template) } return template} This is kind of a mind-blowing concept, but the uniqueness of the tokens array essentially means that we can ensure that each call to html...`` only parses the HTML once.

Next, we just need a way to update the cloned DOM node with the expressions array (which is likely to be different every time, unlike tokens).

To keep things simple, let’s just replace the expressions array with a placeholder for each index:

const stubs = expressions.map((\_, i) => `\_\_stub-${i}\_\_`) If we zip this up like before, it will create this HTML:

```

\_\_stub-1\_\_

``` We can write a simple string replacement function to replace the stubs:

function replaceStubs (string) { return string.replaceAll(/\_\_stub-(\d+)\_\_/g, (\_, i) => ( expressions[i] ))} And now whenever the html function is called, we can clone the template and update the placeholders:

const element = cloned.firstElementChildfor (const { name, value } of element.attributes) { element.setAttribute(name, replaceStubs(value))}element.textContent = replaceStubs(element.textContent) Note: we are using firstElementChild to grab the first top-level element in the template. For our toy framework, we’re assuming there’s only one.

Now, this is still not terribly efficient – notably, we are updating textContent and attributes that don’t necessarily need to be updated. But for our toy framework, this is good enough.

We can test it out by rendering with different state:

document.body.appendChild(render({ color: 'blue', text: 'Blue!' }))document.body.appendChild(render({ color: 'red', text: 'Red!' })) This works!

Step 3: combining reactivity and DOM renderingSince we already have a createEffect from the rendering system above, we can now combine the two to update the DOM based on the state:

const container = document.getElementById('container')createEffect(() => { const dom = render(state) if (container.firstElementChild) { container.firstElementChild.replaceWith(dom) } else { container.appendChild(dom) }}) This actually works! We can combine this with the “sum” example from the reactivity section by merely creating another effect to set the text:

createEffect(() => { state.text = `Sum is: ${state.sum}`}) This renders “Sum is 3”:

You can play around with this toy example. If you set state.a = 5, then the text will automatically update to say “Sum is 7.”

Next stepsThere are lots of improvements we could make to this system, especially the DOM rendering bit.

Most notably, we are missing a way to update content for elements inside a deep DOM tree, e.g.:

```

${text}

``` For this, we would need a way to uniquely identify every element inside of the template. There are lots of ways to do this:

  1. Lit, when parsing HTML, uses a system of regexes and character matching to determine whether a placeholder is within an attribute or text content, plus the index of the target element (in depth-first TreeWalker order).
  2. Frameworks like Svelte and Solid have the luxury of parsing the entire HTML template during compilation, which provides the same information. They also generate code that calls firstChild and nextSibling to traverse the DOM to find the element to update.

Note: traversing with firstChild and nextSibling is similar to the TreeWalker approach, but more efficient than element.children. This is because browsers use linked lists under the hood to represent the DOM.

Whether we decided to do Lit-style client-side parsing or Svelte/Solid-style compile-time parsing, what we want is some kind of mapping like this:

[ { elementIndex: 0, // <div> above attributeName: 'class', stubIndex: 0 // index in expressions array }, { elementIndex: 1 // <span> above textContent: true, stubIndex: 1 // index in expressions array }] These bindings would tell us exactly which elements need to be updated, which attribute (or textContent) needs to be set, and where to find the expression to replace the stub.

The next step would be to avoid cloning the template every time, and to just directly update the DOM based on the expressions. In other words, we not only want to parse once – we want to only clone and set up the bindings once. This would reduce each subsequent update to the bare minimum of setAttribute and textContent calls.

Note: you may wonder what the point of template-cloning is, if we end up needing to call setAttribute and textContent anyway. The answer is that most HTML templates are largely static content with a few dynamic “holes.” By using template-cloning, we clone the vast majority of the DOM, while only doing extra work for the “holes.” This is the key insight that makes this system work so well.

Another interesting pattern to implement would be iterations (or repeaters), which come with their own set of challenges, like reconciling lists between updates and handling “keys” for efficient replacement.

I’m tired, though, and this blog post has gone on long enough. So I leave the rest as an exercise to the reader!

ConclusionSo there you have it. In the span of one (lengthy) blog post, we’ve implemented our very own JavaScript framework. Feel free to use this as the foundation for your brand-new JavaScript framework, to release to the world and enrage the Hacker News crowd.

Personally I found this project very educational, which is partly why I did it in the first place. I was also looking to replace the current framework for my emoji picker component with a smaller, more custom-built solution. In the process, I managed to write a tiny framework that passes all the existing tests and is ~6kB smaller than the current implementation, which I’m pretty proud of.

In the future, I think it would be neat if browser APIs were full-featured enough to make it even easier to build a custom framework. For example, the DOM Part API proposal would take out a lot of the drudgery of the DOM parsing-and-replacement system we built above, while also opening the door to potential browser performance optimizations. I could also imagine (with some wild gesticulation) that an extension to Proxy could make it easier to build a full reactivity system without worrying about details like flushing, batching, or cycle detection.

If all those things were in place, then you could imagine effectively having a “Lit in the browser,” or at least a way to quickly build your own “Lit in the browser.” In the meantime, I hope that this small exercise helped to illustrate some of the things framework authors think about, and some of the machinery under the hood of your favorite JavaScript framework.

Thanks to Pierre-Marie Dartus for feedback on a draft of this post.

Footnotes1. Now that we’ve built the framework, you can see why the content passed to innerHTML can be considered trusted. All HTML tokens either come from tagged template literals (in which case they’re fully static and authored by the developer) or are placeholders (which are also written by the developer). User content is only set using setAttribute or textContent, which means that no HTML sanitization is required to avoid XSS attacks. Although you should probably just use CSP anyway!

View Details

Here’s a deep-in-the-weeds thing about web components that I ran into recently.

Let’s say you have a humble component:

class Hello extends HTMLElement {}customElements.define('hello-world', Hello); And let’s say that this component throws an error in its connectedCallback:

class Hello extends HTMLElement { connectedCallback() { throw new Error('haha!'); }} Why would it do that? I dunno, maybe it needs to validate its props or something. Or maybe it’s just having a bad day.

In any case, you might wonder: how could you test this functionality? You might naïvely try a try/catch:

const element = document.createElement('hello-world');try { document.body.appendChild(element);} catch (error) { console.log('Caught?', error);} Unfortunately, this doesn’t work:

In the DevTools console, you’ll see:

Uncaught Error: haha! Our elusive error is uncaught. So… how can you catch it? In the end, it’s fairly simple:

window.addEventListener('error', event => { console.log('Caught!', event.error);});document.body.appendChild(element); This will actually catch the error:

As it turns out, connectedCallback errors bubble up directly to the window, rather than locally to where you called appendChild. (Even though appendChild is what caused connectedCallback to fire in the first place. For the spec nerds out there, this is apparently called a custom element callback reaction.)

Our addEventListener solution works, but it’s a little janky and error-prone. In short:

  • You need to remember to call event.preventDefault() so that nobody else (like your persnickety test runner) catches the error and fails your tests.
  • You need to remember to call removeEventListener (or AbortSignal if you’re fancy).

A full-featured utility might look like this:

function catchConnectedError(callback) { let error; const listener = event => { event.preventDefault(); error = event.error; }; window.addEventListener('error', listener); try { callback(); } finally { window.removeEventListener('error', listener); } return error;} …which you could use like so:

const error = catchConnectedError(() => { document.body.appendChild(element);});console.log('Caught!', error); If this comes in handy for you, you might add it to your testing library of choice. For instance, here’s a variant I wrote recently for Jest.

Hope this quick tip was helpful, and keep connectin’ and errorin’!

Update: This is also true of any other “callback reactions” such as disconnectedCallback, attributeChangedCallback, form-associated custom element lifecycle callbacks, etc. I’ve just found that, most commonly, you want to catch errors from connectedCallback.

View Details

Dave Rupert recently made a bit of a stir with his post “If Web Components are so great, why am I not using them?”. I’ve been working with web components for a few years now, so I thought I’d weigh in on this.

At the risk of giving the most senior-engineer-y “It depends” answer ever: I think web components have strengths and weaknesses, and you have to understand the tradeoffs before deciding when to use them. So let’s explore some cases where web components really shine, before moving on to where they might fall flat.

Client-rendered leaf componentsTo me, this is the most unambiguously slam-dunk use case for web components. You have some component at the leaf of the DOM tree, it doesn’t need to be rendered server-side, and it doesn’t <slot> any content inside of it. Examples include: a rich text editor, a calendar widget, a color picker, etc.

At this point, you’ve already bypassed a bunch of tricky bits of web components, such as Server-Side Rendering (SSR), hydration, slotting, maybe even shadow DOM. If you’re not using a framework, or you’re using one that supports web components, you can just plop the <fancy-component> tag into your template or JSX and call it a day.

For instance, take my emoji-picker-element. It’s one line of HTML to import it:

```

``` And one line to use it:

<emoji-picker></emoji-picker> No bundler, no transpiler, no framework integration, just copy-paste. It’s almost like ye olde days of jQuery plugins. And yet, I’ve also seen it used in complex SPA projects – web components can run the gamut.

This is about as close as you can get to the original vision for web components, which is that using <fancy-element> should be as easy as using built-in HTML elements.

Glue code, or avoiding the big rewritePicture this: you’ve got a big React codebase, it’s served you well for a while, but now your team wants to move to Svelte. Time to rewrite the whole thing, right? Including finding new Svelte versions of every third-party component you’re using?

This is the way a lot of frontend devs think about frameworks, with all these huge switching costs when moving from one to the other. The biggest misconception I’ve seen about web components is that they’re just another flavor of the same story.

They’re not. The whole point of web components is to liberate us from this churn. If you decide to switch from Vue to Lit, or from Angular to Stencil (or whatever), and if you’re rewriting all your components in one go, then you’re signing up for a lot of unnecessary pain.

Just let your old code and your new code live side-by-side. Use web components as the interoperability later to glue the two together. You don’t need to rewrite everything all at once:

<old-component> <new-component> </new-component></old-component> Web components can pass props/attributes down, and send events back up. (That’s kind of their whole thing.) If your framework supports web components, then this works out of the box. (And if not, you can write some lite glue code.)

Now, some people get squeamish at the idea of two frameworks living on the same page, but I think this is more gut-based than evidence-based. And to be fair, if you’re using a meta-framework to do SSR/hydration, then this partial migration may be easier said than done. But if web components are good at anything, it’s defining a high-level contract for composing two components together, on the client side anyway.

So if you’re tired of rewriting your whole UI every year (or your boss is tired of you doing it), then maybe web components are worth considering.

Design systems and enterpriseIf you watch Cassondra Robert’s talk from CSS Day, there’s a nice slide with a lot of corporate logos attesting to web components’ penetration:

If this isn’t enough, you could also look at Oracle, SAP, ServiceNow… the list goes on and on.

What you’ll notice is that a lot of big companies (like the one I work for) are quietly using web components, especially in their design systems and component libraries. If you spend a lot of time on webdev social media, this might surprise you. It might also surprise you to learn that, by some measures, React is used on roughly 8% of page loads, whereas web components are used on 20%.

The thing is, a lot of big companies are not on social media (Twitter/X, Reddit, etc.) trying to sell you on web components or teach you how to use them. On the other hand, there are plenty of tech influencers on Twitter busily keeping up to date with every minor version of React and what’s new in that ecosystem. The reason for this is pretty simple: big companies tend to talk a lot internally, but not so much externally, whereas small companies (agencies, startups, freelancers, etc.) tend to be more active on social media relative to their company size. So if web components are more popular inside the enterprise than outside of it, you’d never know it from browsing Twitter all day.

So why are big enterprises so gaga for web components? For one thing, design systems based on web components work across a variety of environments. A big company might have frontends written in React, Angular, Ember, and static HTML, and they all have to play nicely with the company’s theming and branding. The big rewrite (as described above) may be a fun exercise for your average startup, but it’s just not practical in the enterprise world.

Having a lot of consumers of your codebase, and having to think on longer timescales, just leads to different technical decisions. And to me, this points to the main reason enterprises love web components: stability and longevity.

Think about your average React codebase, and how updating any dependency (React Router, Redux, React itself, etc.) can lead to a weeks-long effort of rewriting your code to accommodate all the breaking changes. Cue the inevitable appearance of Hyrum’s Law at enterprise scale, where even a tiny change can cause a butterfly effect that breaks thousands of components, and even bumping a minor version can lead to weeks of testing, validating, and documenting. In this world, your average React minor version bump is an ordeal – a major version bump is a disaster.

Compare this to the backwards-compatibility guarantees of the web platform, where the venerable Space Jam website from 1996 still works to this day. Web components hook into this stability story, which is a huge plus for any company that doesn’t have the luxury of rewriting their frontend every couple years.

When you use a web component, connectedCallback is just connectedCallback – it’s not going to change. And shadow DOM style scoping, with all of its subtleties, is not going to change either. Whatever code you can delegate to the browser, that’s code you’re not having to maintain or validate over the years; you’ve effectively outsourced that responsibility to Google, Apple, and Mozilla.

Enterprises are slow, cautious, and risk-averse – just like the web platform. No wonder web components have taken the enterprise world by storm.

Downsides of web componentsAll of the pluses of web components should be weighed against their weaknesses. And web components have their fair share:

  • Server-side rendering (SSR). I would argue that this is still not a solved problem in web-components-land. Sure, we have Declarative Shadow DOM, but that’s just one part of the puzzle. There’s no standard for rendering web components on the server, so every framework does it a bit differently. The fact that Lit SSR is still under “Lit Labs” should tell you something. Maybe in the future, when you can render 3 different web component frameworks on the server, and they compose together and hydrate nicely, then I’ll consider this solved. But I think we’re a few years away from that, at least.
  • Accessibility. You can’t have ARIA references that easily cross shadow boundaries, and dialogs and focus can be tricky. At the very least, if you don’t want to mess up accessibility, then you have to think really carefully about your component structure from day one. There’s a lot of ongoing work to solve this, but I’d say it’s definitely rough out there in 2023.

Aside from that, there are also problems of lock-in (e.g. meta-frameworks, bundlers, test runners), the ongoing legacy of IE11 (some folks are scarred for life; the last thing they want to do is #useThePlatform), and overall platform exhaustion (“I learned React, it works, I don’t want to learn something else”). Not everyone is going to be sold on web components, and I’m fine with that. The web is a big tent, and everybody is using it for different things; that’s part of what makes it so amazing.

ConclusionUse web components. Or don’t use them. Or come back and check in again in a few years, when the features and web standards are a bit more fleshed out.

I think web components are cool, but I understand that not everyone feels the same way. I don’t feel like I need to go around evangelizing for their adoption. They’re just another tool in the toolbelt; the trick is leveraging their strengths while avoiding their pitfalls.

The thing I like about web components, and web standards in general, is that I get to outsource a bunch of boring problems to the browser. How do I compose components? How do I scope styles? How do I pass data around? Who cares – just take whatever the browser gives you. That way, I can spend more time on the problems that actually matter to my end-users, like performance, accessibility, security, etc.

Too often, in web development, I feel like I’m wrestling with incidental complexity that has nothing to do with the actual problem at hand. I’m wrangling npm dependencies, or debugging my state manager, or trying to figure out why my test runner isn’t playing nicely with my linter. Some people really enjoy this kind of stuff, and I find myself getting sucked into it sometimes too. But I think ultimately it’s a kind of fake-work that feels good but doesn’t accomplish much, because your end-user doesn’t care if your bundler is up-to-date with your TypeScript transpiler.

That said, in 2023, choosing web components comes with its own baggage of incidental complexity, such as the aforementioned problems of SSR and accessibility. Compromising on either of those things could actively harm your end-users in ways that actually matter to them, so the tradeoff may not be worth it to you.

I think the tradeoff is often worth it, but again, there are nuances here. “Use web components for what they’re good at” isn’t catchy, but it’s a good way to think about it in 2023.

Thanks to Westbrook Johnson for feedback on a draft of this blog post.

View Details

A few months ago, I gave a talk on CSS performance at performance.now in Amsterdam. The recording is available online:

(You can also read the slides.)

This is one of my favorite talks I’ve ever given. It was the product of months (honestly, years) of research, prompted by a couple questions:

  1. What is the fastest way to implement scoped styles? (Surprisingly few people seem to ask this question.)
  2. Does using shadow DOM improve style performance? (Short answer: yes.)

To answer these questions (and more), I did a bunch of research into how browsers work under the hood. This included combing through old Servo discussions from 2013, reaching out to browser developers like Manuel Rego Casasnovas and Emilio Cobos Alvarez, reading browser PRs, and writing lots of benchmarks.

In the end, I’m pretty satisfied with the talk. My main goal was to shine a light on all the heroic work that browser vendors have done over the years to make CSS so performant. Much of this stuff is intricate and arcane (like Bloom filters), but I hoped that with some simple diagrams and animations, I could bring this work to life.

The two outcomes I’d love to see from this talk are:

  1. Web developers spend more time thinking about and measuring CSS performance. (Pssst, check out the SelectorStats section of my guide to Chrome tracing!)
  2. Browser vendors provide better DevTools to understand CSS performance. (In the talk, I pitch this as a SQL EXPLAIN for CSS.)

What I didn’t want to do in this talk was rain on anybody’s parade who is trying to do sophisticated things with CSS. More and more, I am seeing ambitious usage of new CSS features like :has and container queries, and I don’t want people to feel like they should avoid these techniques and limit themselves to classes and IDs. I just want web developers to consider the cost of CSS, and to get more comfortable with using the DevTools to understand which kinds of CSS patterns may be slowing down their website.

I also got some good feedback from browser DevTools folks after my talk, so I’m hopeful for the future of CSS performance. As techniques like shadow DOM and native CSS scoping become more widespread, it may even mitigate a lot of my worries about CSS perf. In any case, it was a fascinating topic to research, and I hope that folks were intrigued and entertained by my talk.

View Details

Five years ago, I started a journey to build a better Mastodon client – one focused on performance and simplicity. And I did! Pinafore is the main Mastodon client I’ve used myself since I first released it.

After five years, though, my relationship with social media has changed, and it’s time for me to put Pinafore out to pasture. The pinafore.social website will still work, but I’ve marked the repo as unmaintained.

Why retire Pinafore?I don’t have the energy to do this anymore. Pinafore has gone from being a fun side project to being a source of dread for me. There is a constant stream of bug reports, feature requests, and pull requests to manage, and I just don’t want to spend my free time doing this anymore.

By the way, this is not my first rodeo. Read this post on my breakup with another open-source project.

Why not pass it off to a new maintainer?Running a fediverse client requires trust. People who use Pinafore are trusting me to handle their data securely. As such, I’ve been meticulous about using good security headers and making pro-privacy decisions. A new maintainer (through malice or ignorance) could add new functionality that compromises on security or privacy, essentially trading on my good name while harming users.

Over the years, I have had lots of feature requests that would inadvertently cause a privacy or security leak, and I’ve pushed back on every single one. (E.g. “Why not contact third-party servers to show the full favorite/boost count?” Well, because users may trust their home server, but that doesn’t mean they trust random third-party servers.)

Rather than trust that a new maintainer will keep these high standards in place, I’d rather put Pinafore in a frozen state.

Why not shut it down entirely?Thanks to Vercel’s generous free tier, Pinafore costs me $0 per month to run. It’s just static HTML/CSS/JS files, after all.

Why are you the sole maintainer?I’m not – there have been tons of contributions through the years. But for the most part, these have been “drive-by” in nature (nothing wrong with that!), rather than someone deeply learning the codebase end-to-end.

I suspect one of the reasons for this is that Pinafore is written in Svelte v2 and Sapper – both of which are deprecated in favor of Svelte v3 and SvelteKit. Not only is there no migration path from Svelte v2 to v3, but there isn’t one from Sapper to SvelteKit either. (And on top of that, I had to fork Sapper pretty heavily.) Anyone making a bet on learning Pinafore’s tech stack is investing in a dead framework, so it’s not very attractive for new maintainers.

So why didn’t I bother updating it? Well, it’s a lot of work to manually migrate 200+ components to what is essentially a new framework. And plus, as far as I could tell, it would be a pure DX (Developer Experience) improvement, not a UX (User Experience) improvement. (I just wouldn’t be using any of SvelteKit’s new features, and Svelte v3 doesn’t seem to have massive UX improvements over Svelte v2.)

What did you learn while writing Pinafore?Now here’s an interesting question! And one that may be useful for those building their own Mastodon (or fediverse) clients. It is my sincerest wish that Pinafore inspires other developers to build their own (and better!) clients.

API and offlineFirst off, ActivityPub does have a client-to-server API, but as far as I can tell, it’s not really worth implementing. Mastodon is the 800-pound gorilla in the fediverse, it doesn’t implement this API, and other servers (such as Pleroma and Misskey) implement their own flavor of Mastodon’s API. And plus, Mastodon’s REST API is pretty sensible and doesn’t change too frequently. (And when it does, they add a /v2 endpoint while still maintaining the /v1 version.)

However, the fact that Mastodon has a fairly bog-standard REST API makes it pretty difficult to implement offline support, as I did in Pinafore. Essentially, I implemented a full mirror of Mastodon’s PostgreSQL database structure, but on top of IndexedDB. On top of that, I had to implement a variety of strategies to synchronize data between the client and server:

  • As new statuses stream in, how do you backfill ones you may have missed if the user went offline? Well, you have to just keep fetching statuses to fill the gap.
  • How do you deal with deleted statuses? Well, you have to remove them from the in-memory store, and the database, and then also go ahead and delete any statuses that boosted them or notifications that reference them… It’s a lot. (And don’t get me started on editing statuses! I didn’t even get around to that.)
  • How to deal with slow servers? Well, you can implement an optimistic UI that shows (for instance) a “favorited” animation while still waiting for the server to respond. (And also cancels if the server responds with an error or times out.)

From my years working on PouchDB, I know that it’s a fool’s errand to try to implement proper client-server synchronization without a holistic plan for managing revisions, conflicts, and offline states… and yet, I did it. The end result is pretty impressive in my opinion, even if arguably it doesn’t add a lot to the user experience. (There’s not much you can do in a social media app when you’re offline, and I’m sure people still frequently have to refresh when stuff gets out-of-date.)

PerformanceSpeaking of which, refreshes should be fast! And I believe Pinafore is pretty good at this. (I can’t find the link, but someone did a recent analysis showing that Pinafore uses less CPU and memory than the default Mastodon frontend.)

In short, I’d say it’s entirely possible to build a performant SPA (despite some of my misgivings about SPAs). But it helps if:

  • You have a browser perf background (like me).
  • You’re only one developer. (Much harder to implement tricky perf optimizations if you have to explain it to your colleagues!)
  • You use a perf-focused framework like Svelte.
  • You don’t do much! Pinafore has a fraction of the features of the main Mastodon frontend.
  • You’re merciless about removing dependencies, or writing your own dependencies when the existing ones are too slow or bloated.
  • You’re meticulous about little micro-optimizations (e.g. debouncing, event delegation, or page splitting) that improve the user experience, especially on low-end devices, but make the developer experience a lot worse.

Not all of this is necessary to make a fast, fluid API, but it certainly helps. And the fact that I ended up building something that can run on feature phones gives me a lot of satisfaction.

AccessibilityI didn’t set out to write “the accessible Mastodon client,” but I’ve heard from a lot of folks that Pinafore is one of the better ones out there, especially for blind users.

For this, I mostly have to thank Marco Zehe and James Teh (among others), who provided tons of feedback and really helped with the polish of the screen reader experience. Accessibility isn’t always black-and-white – like anything in design, sometimes there are tradeoffs and differing opinions on what the best option is. Leaning on the expertise of actual blind users gave me insights that I couldn’t have had otherwise.

Another thing that helps is just giving a damn. When I started on Pinafore, I didn’t really know much about accessibility, but I decided it was time to finally learn. I started off with a basic intro to screen readers from Rob Dodson, played around with VoiceOver and NVDA, and tried to read and understand as much as I could. I wouldn’t call myself an accessibility expert, but I’ve made a lot of progress in the past five years, and now I wince when I look back at some of the code I wrote in the past.

In the end, I found accessibility to be quite rewarding. Rather than feeling like a chore or a box-ticking exercise, it feels like a fun challenge. In some cases it’s just about leaning on existing web standards, but in other cases it feels like you’re building a parallel semantic UI to the visual one. Sometimes I found that this even influenced the overall architecture of my code – which goes to show that it’s better to consider accessibility upfront rather than as an afterthought.

That said, I definitely messed up some stuff when it comes to accessibility – color contrast in particular is something I did a poor job on. (Luckily Nick Colley has put a bunch of work into Pinafore to improve this!)

ConclusionPinafore was a fun project. I learned a lot about web development while working on it. Often, when a new feature landed in browsers – e.g. color-scheme, maskable icons, or various Intl APIs – I would eagerly integrate it into Pinafore, which helped me learn more about how browsers work.

In another case, I went a bit overboard on building my own emoji picker for Pinafore, and in the process learned way more than I ever wanted to know about fonts and emoji rendering.

I also think that Pinafore accomplished many of the goals I had in mind when I originally wrote it. At the time, Mastodon only had a multi-column UI, which many users found overwhelming and confusing. Pinafore demonstrated that a single-column layout could be a viable alternative, and since then, Mastodon has defaulted to a single-column layout.

Back then, there was also only one web-based Mastodon client (Halcyon), and it didn’t support logging in to more than one instance at a time. Pinafore proved it was possible for a web-based client to do this (not obvious given CORS constraints!), and nowadays there are lots of web-based clients, such as Sengi, Cuckoo+, and Elk, and many of them support multi-instance logins.

Pinafore isn’t going anywhere – like I mentioned, the site is still up and running. I also think it could serve as an interesting point of comparison for other Mastodon clients. (Try to beat Pinafore on performance and accessibility! I think that would be a great outcome.)

I also want to thank everyone who followed along with me on this journey over the years, and who either used Pinafore, filed a bug, or contributed to it. Thank you for giving me one of my career-defining projects over the last half-decade. It wouldn’t have been possible without your help.

View Details

Once again, here are the books I read this year, and especially the ones I’d recommend.

One interesting thing I noticed about this year: in years past, I mentioned trying to read more books written by women. Well this year, without consciously trying, 9 out of the 13 books I read were written by women. I’d pat myself on the back, but if I did a full accounting of all the books I’ve read in my lifetime, I probably have a huge deficit to make up.

In any case, bring on the books!

Quick linksFiction* The MaddAddam trilogy by Margaret Atwood (2003-2013) * The Cage by Audrey Schulman (1994) * The Dolphin House by Audrey Schulman (2022) * Their Eyes Were Watching God by Zora Neale Hurston (1937) * After Dark by Haruki Murakami (2004) * The Concubine by Norah Lofts (1963) * The Bell Jar by Sylvia Plath (1963) * The Alchemist by Paulo Caoelho (1988) * Go Tell It on the Mountain by James Baldwin (1953) * No One Is Talking About This by Patricia Lockwood (2021)

Nonfiction* Midnight in Chernobyl by Adam Higginbotham (2019) * Out of the Software Crisis by Baldur Bjarnason (2022)

FictionThe MaddAddam trilogy by Margaret Atwood (2003-2013)Probably my favorite books I read all year. I’m a sucker for good sci-fi, and Atwood’s feels especially prescient. I can’t believe the first one (Oryx and Crake) was written almost two decades ago – the concerns about genetic manipulation and climate change are very top-of-mind nowadays.

The first two books are equally compelling, and they feel like separate, self-contained novels. Whereas the third one (while less thrilling to me) does a good job of bringing the two storylines together and tying up loose ends.

Also, this trilogy is just begging to be made into a prestige TV series – I wouldn’t be surprised if we get one in the next few years.

The Cage by Audrey Schulman (1994)Audrey Schulman is quickly becoming one of my favorite writers. She writes true science fiction – with an emphasis on the “science” part. In each of her books you can tell she really does her research. In this one in particular, there are so many little touches (like the details on how cameras react to the extreme cold, or how frostbite feels on the skin) that you know she must have dug deep to bring this story to life.

Add on the vivid characters – she’s especially good at communicating what it feels like to be a woman in a male-dominated environment, in a “Jody Foster in Silence of the Lambs” kind of way – and you end up with an amazing first novel.

The Dolphin House by Audrey Schulman (2022)The latest book from Audrey Schulman, and equally as good as her first one. It’s best to read it without reading any blurbs about what it’s about. I’ll just say that, once again, the amount of research she does (especially into animal behavior) and the depth of her characters, make for an absorbing and satisfying read.

Their Eyes Were Watching God by Zora Neale Hurston (1937)A moving story about love and loss. At first it reminded me a bit of Madame Bovary (the boredom of a cooped-up housewife), but the story moves at such a brisk pace with so many different subplots that you can’t really compare it to one single thing. Somber at times, funny at others, ultimately uplifting.

After Dark by Haruki Murakami (2004)A strange book (aren’t all Murakami books strange?) but a compelling one. What Steven King has for horror, Murakami seems to have for these kinds of uncanny situations that defy explanation. A short, fun read.

The Concubine by Norah Lofts (1963)There are plenty of books, movies, and TV miniseries about the Anne Boleyn story, but this one was recommended to be as one of the best characterizations. Here we see Anne mostly as a tragic figure – a teenage girl who tries to master her own destiny but gets in way over her head. Meanwhile, Henry mostly comes off as a lecherous boor, quick to invent whatever moral authority he needs in the moment to justify his whims. A great but somewhat depressing book.

The Bell Jar by Sylvia Plath (1963)I really enjoyed the first half of this book – the story of an ambitious but detached teenager is something I can personally identify with. The second half is where I lost interest, maybe because mental health and depression aren’t something I’ve had to deal with much. I imagine this book must have been a jaw-dropper when it was first released, at a time when mental health issues weren’t discussed with this much candor. I also think I would have enjoyed this book more when I was a mopey teenager.

The Alchemist by Paulo Caoelho (1988)A strange little book, somewhat reminiscent of The Little Prince. I can’t say I really loved it, but it’s a nice short story.

Go Tell It on the Mountain by James Baldwin (1953)The family drama and meditations on faith and hypocrisy stuck with me the most, although I can’t say this was my favorite book I read this year. Worth a read, though.

No One Is Talking About This by Patricia Lockwood (2021)The first half is a pretty good approximation of what it feels like to mainline Twitter directly into your veins. I’m ashamed of how many of the references I got. However, it feels like the book runs out of steam about halfway through, like it was trying to make a point about how real-life events can tear you away from “the portal,” but it didn’t quite land for me.

NonfictionMidnight in Chernobyl by Adam Higginbotham (2019)I was enthralled by the HBO miniseries Chernobyl, so I picked up this book. It’s equally riveting, and I found it hard to put down. Something about the horror of the event, combined with the banality of the bureaucratic fumbling around it, fills me with awe and fascination. And honestly, some of the descriptions of toadying, cover-ups, and fudging of the truth that were rife in the Soviet Union remind me more than a little bit of working in big companies (although thankfully the stakes are substantially lower than Chernobyl).

Out of the Software Crisis by Baldur Bjarnason (2022)A great book on software development, and one I might need to re-read. So much of our industry feels like it’s driven by hearsay, hunches, and charisma (what Bjarnason calls “the pop culture”), and as an antidote to that, this book is like a breath of fresh air.

View Details

Shadow DOM is a kind of retcon for the web. As I’ve written in the past, shadow DOM upends a lot of developer expectations and invalidates many tried-and-true techniques that worked fine in the pre-shadow DOM world. One potentially surprising example is ARIA. Quick recap: shadow DOM allows you to isolate parts of your DOM […]

View Details

Five years ago, I was all-in on Mastodon. I deleted my Twitter account, set up a Mastodon instance, and encouraged my friends to join. A year later, I wrote my own Mastodon client in an attempt to make Mastodon faster and easier to use. So with the recent Twitter exodus, and with seemingly every news […]

View Details

I’ve been doing web performance for a while, so I’ve spent a lot of time in the Performance tab of the Chrome DevTools. But sometimes when you’re debugging a tricky perf problem, you have to go deeper. That’s where Chrome tracing comes in. Chrome tracing (aka Chromium tracing) lets you record a performance trace that […]

View Details

I was fascinated recently by “Why we’re breaking up with CSS-in-JS” by Sam Magura. It’s a great overview of some of the benefits and downsides of the “CSS-in-JS” pattern, as implemented by various libraries in the React ecosystem. What really piqued my curiosity, though, was a link to this guide by Sebastian Markbåge on potential […]

View Details

I’ve been thinking a lot recently about Single-Page Apps (SPAs) and Multi-Page Apps (MPAs). I’ve been thinking about how MPAs have improved over the years, and where SPAs still have an edge. I’ve been thinking about how complexity creeps into software, and why a developer may choose a more complex but powerful technology at the […]

View Details

Last year, I asked the question: Does shadow DOM improve style performance? I didn’t give a clear answer, so perhaps it’s no surprise that some folks weren’t sure what conclusion to draw. In this post, I’d like to present a new benchmark that hopefully provides a more solid answer. TL;DR: My new benchmark largely confirmed […]

View Details

Last year, I wrote about managing focus in the shadow DOM, and in particular about modal dialogs. Since the

element has now shipped in all browsers, and the inert attribute is starting to land too, I figured it would be a good time to take another look at getting dialogs to play nicely with […]

View Details

In 1988, the anthropologist Joseph Tainter published a book called The Collapse of Complex Societies. In it, he described the rise and fall of great civilizations such as the Romans, the Mayans, and the Chacoans. His goal was to answer a question that had vexed thinkers over the centuries: why did such mighty societies collapse? […]

View Details

When I write about web development, sometimes it feels like the parable of the blind men and the elephant. I’m out here eagerly describing the trunk, someone else protests that no, it’s a tail, and meanwhile the person riding on its back is wondering what all the commotion is down there. We’re all building so […]

View Details

My last post (“The balance has shifted away from SPAs”) attracted a fair amount of controversy, so I’d like to do a follow-up post with some clarifying points. First off, a definition. In some circles, “SPA” has colloquially come to mean “website with tons of JavaScript,” which brings its own set of detractors, such as […]

View Details

There’s a feeling in the air. A zeitgeist. SPAs are no longer the cool kids they once were 10 years ago. Hip new frameworks like Astro, Qwik, and Elder.js are touting their MPA capabilities with “0kB JavaScript by default.” Blog posts are making the rounds listing all the challenges with SPAs: history, focus management, scroll […]

View Details

Emoji are a standard overseen by the Unicode Consortium. The web is a standard governed by bodies such as the W3C, WHATWG, and TC39. Both emoji and the web are ubiquitous. So you might be forgiven for thinking that, in 2022, it’s possible to plop an emoji on a web page and have it “just […]

View Details

It’s been almost five years since I deleted my Twitter account. I didn’t just delete the app or deactivate – I deleted my whole account and my entire tweet history, lighting a match and burning the bridge behind me. I don’t want to pretend to be some kind of seer, but since then, divesting yourself […]

View Details

I’ve researched and learned enough about client-side memory leaks to know that most web developers aren’t worrying about them too much. If a web app leaks 5 MB on every interaction, but it still works and nobody notices, then does it matter? (Kinda sounds like a “tree in the forest” koan, but bear with me.) […]

View Details

I’ve been doing end-of-the year book reviews for almost 5 years now. At this point I have to ask myself: why am I still doing this? To encourage myself to read more? To show off? To convince myself that this blog is about more than just tech stuff? There may be some truth to all […]

View Details

Debugging memory leaks in web apps is hard. The tooling exists, but it’s complicated, cumbersome, and often doesn’t answer the simple question: Why is my app leaking memory? Because of this, I’d wager that most web developers are not actively monitoring for memory leaks. And of course, if you’re not testing something, it’s easy for […]

View Details

Every so often, I come across a web performance post from what I like to call the “one weird trick” genre. It goes something like this: “I improved my page load time by 50% by adding one line of CSS!” or “It’s 2x faster to use this JavaScript API than this other one!” The thing […]

View Details

I’ve been writing about performance for a long time. I like to think I’ve gotten pretty good at it, but sometimes I look back on my older blog posts and cringe at the mistakes I made. This post is an attempt to distill some of what I’ve learned over the years to offer as advice […]

View Details

Ten years ago I would have considered myself someone who was excited about new technology. I always had the latest smartphone, I would read the reviews of new Android releases with a lot of interest, and I was delighted when things like Google Maps Navigation, speech-to-text, or keyboard swiping made my life easier. Nowadays, to […]

View Details

Recently I read James Long’s article “A future for SQL on the web”. It’s a great post, and if you haven’t read it, you should definitely go take a look! I don’t want to comment on the specifics of the tool James created, except to say that I think it’s a truly amazing feat of […]

View Details

Short answer: Kinda. It depends. And it might not be enough to make a big difference in the average web app. But it’s worth understanding why. First off, let’s review the browser’s rendering pipeline, and why we might even speculate that shadow DOM could improve its performance. Two fundamental parts of the rendering process are […]

View Details

For me, one of the most aggravating performance issues on the web is when it’s slow to type into a text input. I’m a fairly fast typist, so if there’s even a tiny delay in a <textarea> or <input>, I can feel it slowing me down, and it drives me nuts.

I find this problem especially irksome because it’s usually solvable with a few simple tricks. There’s no reason for a chat app or a social media app to be slow to type into, except that web developers often take the naïve approach, and that’s where the delay comes from.

To understand the source of input delays, let’s take a concrete example. Imagine a Twitter-like UI with a text field and a “remaining characters” count. As you type, the number gradually decreases down to zero.

Here’s the naïve way to implement this:

  1. Attach an input event listener to the <textarea>.
  2. Whenever the event fires, update some global state (e.g. in Redux).
  3. Update the “remaining characters” display based on that global state.

And here’s a live example. Really mash on the keyboard if you don’t notice the input delay:

Note: This example contains an artificial 70-millisecond delay to simulate a heavy real-world app, and to make the demo consistent across devices. Bear with me for a moment.

The problem with the naïve approach is that it usually ends up doing far too much work relative to the benefit that the user gets out of the “remaining characters” display. In the worst case, changing the global state could cause the entire UI to re-render (e.g. in a poorly-optimized React app), meaning that as the user types, every keypress causes a full global re-render.

Also, because we are directly listening to the input event, there will be a delay between the actual keypress and the character appearing in the <textarea>. Because the DOM is single-threaded, and because we’re doing blocking work on the main thread, the browser can’t render the new input until that work finishes. This can lead to noticeable typing delays and therefore user frustration.

My preferred solution to this kind of problem is to use requestIdleCallback to wait for the UI thread to be idle before running the blocking code. For instance, something like this:

``` let queued = false textarea.addEventListener('input', () => { if (!queued) { queued = true requestIdleCallback(() => { updateUI(textarea.value) queued = false }) } })

```

This technique has several benefits:

  1. We are not directly blocking the input event with anything expensive, so there shouldn’t be a delay between typing a character and seeing that character appear in the <textarea>.
  2. We are not updating the UI for every keypress. requestIdleCallback will batch the UI updates when the user pauses between typing characters. This is sensible, because the user probably doesn’t care if the “remaining characters” count updates for every single keypress – their attention is on the text field, not on the remaining characters.
  3. On a slower machine, requestIdleCallback will naturally do fewer batches-per-keypress than on a faster machine. So a user on a faster device will have the benefit of a faster-updating UI, but neither user will experience poor input responsiveness.

And here’s a live example of the optimized version. Feel free to mash on the keyboard: you shouldn’t see (much) of a delay!

In the past, you might have used something like debouncing to solve this problem. But I like requestIdleCallback because of the third point above: it naturally adapts to the characteristics of the user’s device, rather than forcing us to choose a hardcoded delay.

Note: Running your state logic in a web worker is also a way to avoid this problem. But the vast majority of web apps aren’t architected this way, so I find requestIdleCallback to be better as a bolt-on solution.

To be fair, this technique isn’t foolproof. Some UIs really need to respond immediately to every keypress: for instance, to disallow certain characters or resize the <textarea> as it grows. (In those cases, though, I would throttle with requestAnimationFrame.) Also, some UIs may still lag if the work they’re doing is large enough that it’s perceptible even when batched. (In the live examples above, I set an artificial delay of 70 milliseconds, which you can still “feel” with the optimized version.) But for the most part, using requestIdleCallback is enough to get rid of any major responsiveness issues.

If you want to test this on your own website, I’d recommend putting the Chrome DevTools at 6x CPU slowdown and then mashing the keyboard as fast as you can. On a vanilla <textarea> or <input> with no JavaScript handlers, you won’t see any delay. Whereas if your own website feels sluggish, then maybe it’s time to optimize your text inputs!