Writing JavaScript that doesn’t stutter: the layout, list, and async mistakes that still jank in 2026
Marcus Webb
August 27, 2026
I still demo sites on a mid-range Android I keep in a drawer for this purpose. The laptop is a liar. Chrome on a M-series machine will forgive a forced reflow that makes a bus ride feel like the UI is wading. In 2026 the frameworks are smarter, the compilers are nosier, and the same three mistakes still make the frame drop: we poke layout in a loop, we render a list like it is a document, and we treat async as a pile of awaits that happen to run in order.
I write frontend for a living. I am not going to tell you to memoize every callback. That is a different sermon, and a bad one. This is the stutter I can still reproduce on a phone after a year of “the compiler will handle it.”
Layout: the read that costs a frame
The classic bug did not die. You write a size, you read a size, you write a size. The browser flushes style and layout because you asked a question that only a laid-out tree can answer. Do that in a loop over rows and you have turned a paint into a slideshow.
I still find it in code that looks modern. A resize observer that immediately reads getBoundingClientRect on every child. A “measure the chip, then set the popover” pair inside a map. A chart library that asks for width in the same tick it applies a class that changes width. React 19 did not make offsetHeight free. The compiler will not save you from asking the document a question in the middle of mutating it.
What I do instead is boring. Batch the writes. Read once, into a variable or a map, after a frame if I have to. If I need a size to position something, I give it a reserved slot in CSS — a min-height, a grid area, an aspect-ratio — so the first paint is stable and the JS can refine. If I cannot reserve, I accept one forced layout at the start of a gesture, not one per item.
The 2026 flavor of this bug is the “we animate with JS because CSS cannot do our design.” Sometimes that is true. Often the design is a transform that CSS can do, plus a leftover read of layout that someone added while debugging. Profile it. If the flame chart says Recalculate Style in a sawtooth, you are not looking at a React problem. You are looking at a Q&A with the render tree.
Container queries and content-visibility helped. They did not help the component that measures itself to decide how many buttons to show and then writes the result in the same function. That component is a layout conversation. Move the conversation out of the pointer handler.

Lists: the document you keep pretending is short
Every app I have shipped in the last three years has a list that “will never be that long.” It is always that long by Thursday. Notifications, logs, search results, a chat, a spreadsheet cosplaying as a web app. We render 2,000 rows because virtualization felt like a premature optimization and the designer wanted every row to be a different height.
Variable height is the tax. Fixed height is a gift you should take if you can. If you cannot, virtualize anyway and live with the jump when an image loads, or reserve the image height. What I will not do in 2026 is mount 2,000 rich rows with hooks, observers, and a hover card “just in case.” The main thread is not a landfill.
The mistakes I still review:
- A key that is the index, so a prepend reshuffles every component and they all remount. The list does not only jank on scroll. It janks on every websocket append.
- A row that starts a fetch in an effect. Scroll a virtualized list fast and you have a thundering herd of requests for rows the user never stopped on. Fetch the window, not the row, or debounce the row until it has been visible for a beat.
- Measuring every row on the way past so the scroll bar can be “accurate.” Accurate scroll bars are nice. A scroll that keeps up with a finger is nicer. Estimate, then correct.
- Putting a
backdrop-filteror a box-shadow on every row. You did not virtualize the GPU. You painted a stack of expensive layers.
Native <table> is not automatically worse than a div soup. A table with content-visibility: auto and a reasonable row size has beaten a “headless UI list” on more than one project I was called in to un-jank. The library is not the sin. The sin is assuming the library’s default window is your data.
If you are on a framework that now compiles away re-renders, congratulations: you still mounted the nodes. Compilation does not virtualize the DOM. I have had that conversation in 2026 with people who thought the compiler was a list strategy. It is a render strategy. Different layer.
Async: the waterfall we renamed “streaming”
The third stutter is not a dropped frame so much as a UI that arrives in apologetic pieces, or worse, a UI that waits for a piece it does not need. We got better APIs. We kept writing waterfalls because waterfalls match how we think: get the user, then the org, then the projects, then the first project’s details.
If the first paint needs a name and an avatar, do not await the org billing plan. If the list can show titles from a cheap endpoint, do not block it on the endpoint that joins five tables. Suspense and streaming help when the boundaries match the user’s patience. They punish you when you suspend a whole page on a slow leaf.
The 2026 mistakes I keep seeing:
useEffectfetch on mount, then another effect that waits on the first state’s id, then a third. That is a waterfall with extra renders. If the data is a graph, fetch a graph, or start the independent requests together.- A transition that stays pending because someone awaited a non-critical call inside the action. The button looks broken. The user clicks again. Now you have two writes.
- JSON that is 4MB because the list endpoint includes the blob you needed on the detail page. Parse cost is main-thread cost. I have profiled “jank on navigation” that was
JSON.parseof a payload nobody displayed yet. - Polling that wakes a list every five seconds and recreates row objects so React, compiler or not, has to think. If the data did not change, do not hand the list a new array identity for sport.
Async jank is also layout jank in costume. Content pops in, heights change, the thing you were about to tap moves. Reserve the space. A skeleton that is the wrong size is still a layout shift. Measure the real row once, then fake that height. I would rather a gray box that holds still than a clever shimmer that shoves the button.

How I debug a stutter without a religion
I reproduce on the slow phone, with CPU throttling if I am stuck on a laptop, and I record a performance trace of the exact gesture: the scroll, the tap, the type. I do not start with a rewrite. I look for long tasks over 50ms on the interaction, layout in a loop, and scripts that run because a list item became visible.
If the trace says layout, I search the handler for reads after writes. If it says a lot of Recalculate Style, I look at selectors and at classes toggled on a root. If it says a lot of painting, I look at shadows, filters, and images without sizes. If it says script, I look at the list window and at parse/JSON. I do not start with Big-O of a helper that runs once.
I also watch the network waterfalls in the same session. A smooth 60fps scroll over empty rows is not a win if the rows arrive two seconds later and jump. Stutter is a feeling. The feeling includes waiting.
What I no longer do
I no longer sprinkle React.memo on a list as a first move. If the list is 2,000 nodes, memo is a tax on a bad idea. I no longer disable virtualization because “the compiler.” I no longer fetch in every row to keep the parent clean. Clean parents with noisy children are how you get a herd.
I no longer trust “it feels fine on my machine” as a QA step. I ride the bus with the drawer phone. I am not being precious. I am matching the device the ad dashboard said our users have.
And I no longer treat INP as a score to game with a busy spinner. If you hide the work behind a disabled button for 400ms, you did not fix the interaction. You scheduled the stutter. Sometimes that is honest. Call it a wait. Do not call it snappy.
The 2026 stack did not repeal physics
Signals, compilers, server components, optimistic UI — I use them. They moved work around. They did not make layout free, lists infinite, or sequential awaits parallel. The stutter I get paid to remove is still, most weeks, one of those three: a layout conversation in a loop, a list that is a novel, an async graph that is a queue.
If you fix those, you can ignore a lot of micro-advice. If you skip them, no amount of memo will make the bus ride kind. Write the reserved height. Window the list. Start the independent fetches together. Then put the phone back in the drawer until the next time someone says the list will never be that long.