The frozen page
Most of us have visited a website where spinners appear, but instead of loading smoothly, the spinner gets choppy. For about a second and a half, the page stops responding—you can’t scroll, click, or see any animations. Then suddenly, the page starts working again.
That problem is called jank, and it happens for a specific reason. I’ll explain why jank happens and what you can do about it. Surma and Jake Archibald talked about this very problem at Google I/O in their ‘the main thread is overworked’ sessions. I first heard their talk years ago and was amazed by how forward-thinking their ideas were. Those concepts are still relevant today, especially since websites do even more now. Let’s get started.
One thread does everything
The main idea of that talk is that the browser’s main thread acts like a single worker doing many jobs. It runs your JavaScript, handles style and layout, paints pixels, responds to clicks and keypresses, and manages animation. One thread does all of this, one job at a time.
If your JavaScript blocks the thread for 1,500ms to sort an array, layout, paint, input, and animation all have to wait their turn. The spinner slows down because the thread that animates it is busy doing something else.
Once you know this, you stop asking “how do I make this code faster” and start asking “should this code even run on the thread that draws my UI?”
The 16ms budget
For smooth 60fps animation, the browser has to create a frame about every 16 milliseconds. That 16ms is known as a performance budget, and it’s a pretty tight one. Ebenezer Scrooge would be proud. The work for that frame—running your JS, layout, and paint—has to fit within that small window.
A 5ms click handler is fine. A 1,500ms sort, however, is not just one missed frame—it’s about ninety frames the browser never gets to draw. Chrome marks any task over 50ms as a “long task” because 50ms already means three missed frames.
React fits into this picture because re-rendering is JavaScript, and JavaScript runs on a single thread. A wasteful render spends milliseconds out of that same 16ms budget. So the defense has two parts:
- Defense #1: Keep the main thread efficient. Avoid wasting resources and focus on good render hygiene.
- Defense #2: Offload work from the main thread. Move heavy, non-UI tasks to workers. This post explains how.
Measure before you optimize
Before making any changes, measure first. The components people assume are slow often aren’t the problem. Open DevTools, select the Performance tab, record, and reproduce the lag. Look for a long task bar with a red corner—that’s usually your culprit, and it’s rarely where you expect.
Two browser primitives also make measuring easy to wire into the app itself:
// Long Tasks API: the browser flags any main-thread task over 50ms.new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log('long task:', Math.round(entry.duration), 'ms'); }}).observe({ entryTypes: ['longtask'] });
// requestAnimationFrame only fires when the thread reaches the render step.// If frames stop, rAF stops—so counting rAF callbacks IS your live FPS meter.If you want to know which parts of your app are re-rendering (and why), the React DevTools Profiler is your friend. It will show you which components blinked awake and what set them off.
Defense #1: keep the main thread cheap (briefly)
Render hygiene is a rabbit hole in itself, so I’ll just flag it for you and keep moving. Here are the usual suspects when it comes to wasted React renders:
- If your props or callbacks keep changing shape, you’ll end up re-rendering child components that didn’t ask for it. That’s when you reach for useMemo and useCallback—React’s way of saying, ‘Hey, only recalculate this if you really have to.’ Just remember, these tools aren’t magic; they cost memory and complexity, so check with the Profiler before you start sprinkling them everywhere.
- If you stash your state too high up in the component tree, a tiny change—a single keystroke, for example—can cause a domino effect, re-rendering half your app for no good reason.
- Trying to render a massive list and update each row is like trying to see the entire fairground while riding a carousel. Virtualization only draws what’s visible on the screen. So instead of trying to see in 360 degrees, we’re only paying attention to what’s immediately in front of us. We’ll see this trick in action in a minute.
- If your JavaScript bundle is the size of a steak you get for free if you eat the whole thing, then using code-splitting lets the browser take smaller bites. That way, the main thread doesn’t have to finish the steak before showing anything on screen.
There’s a ceiling, though. No amount of careful rendering will fix work that just doesn’t belong on the main thread. You can memoize a 1,500-millisecond sort all day, but it will still block the thread that’s supposed to keep your UI feeling quick. When you hit that ceiling, it’s time to move the heavy lifting somewhere else.
Defense #2: leave the main thread
A Web Worker is like hiring a second set of hands for your JavaScript. It gives you true parallelism in the browser. You pass it a job, it gets to work, and your main thread can keep doing its thing without getting bogged down. There are three rules that define how Web Workers behave:
- No DOM access. A worker can’t poke at the page itself. That’s the point: the worker is built for crunching numbers, not for updating what you see on the screen.
- Workers and the main thread communicate by sending messages back and forth. You use postMessage to send something out, and onmessage to listen for replies. They don’t share variables—SharedArrayBuffer is the one deliberate exception, and it makes you earn it—so there’s no sneaky back door for data.
- Whenever you send data to a worker, the browser copies it over the fence using something called structured clone. If you’re sending a big chunk of data, that copying can take noticeable time. It’s usually better to send instructions instead of entire data sets, or use transferable objects like ArrayBuffer if you want to hand off a big pile of bytes without making a copy.
The full round-trip is about ten lines. Let’s look at the worker from the workshop demo:
// main threadconst worker = new Worker(new URL('./jank.worker.ts', import.meta.url), { type: 'module' });worker.postMessage({ size });worker.onmessage = (e) => setResult(e.data.duration); // the UI never froze
// jank.worker.ts — imports the SAME heavySort the main thread would have runimport { heavySort } from '../shared/heavyTask';self.onmessage = (e) => { const start = performance.now(); heavySort(e.data.size); self.postMessage({ duration: performance.now() - start });};Notice that the worker is using the same heavySort function as your main
code. The rest of this series hangs on that one detail.
A worker doesn’t make your code run faster. It’s the same function, taking the same number of milliseconds. The only difference is where those milliseconds get spent. Instead of clogging up the thread that’s responsible for drawing your UI, the work happens off to the side, so your page won’t freeze up.
Picture a kitchen with a second cook instead of one. The braise still takes forty minutes no matter who’s stirring it, but with someone else at that burner, the head cook keeps plating and sending dishes out instead of standing there watching a pot. A worker isn’t a shortcut; it’s just another pair of hands.
Let’s make this less abstract. Take TanStack Table and Plotly, for example
It’s easy to nod along with abstract demos, but it’s a lot harder to turn them into something useful. So let’s map this idea onto two tools you may already be using. When you’re working with data-heavy tables and charts, you run into two different bottlenecks, and each one calls for a different solution:
| Tool | Rendering bottleneck → Defense #1 (main thread) | Data bottleneck → Defense #2 (worker) |
|---|---|---|
| TanStack Table | too many DOM rows → virtualization (TanStack Virtual) | sort / filter / aggregate 100k+ rows → worker |
| Plotly | slow SVG render → WebGL scattergl (OffscreenCanvas in Part 2) | downsample / decimate / aggregate a huge series → worker |
The workshop demo isn’t gaslighting you—it’s running a TanStack Table with 150,000 rows. That’s about as many people as you’d find in a packed football stadium, all trying to squeeze onto your screen at once—that’s one big Zoom call. Virtualization (which is just a fancy way of saying ‘don’t put every row in the DOM at once’) keeps the browser from choking on all that data, so scrolling stays fast. But when you try to sort all those rows, that’s a different beast.
Sorting is pure CPU work, and if you do it on the main thread (the part of the browser that handles what you see and click) the page can lock up. If you hand the sorting off to a web worker (basically a helper running in the background), you’ll find scrolling and clicking stay smooth and responsive.
The important part is what gets sent back and forth. The worker keeps a copy of the data and, instead of sending back all 150,000 rows (which would be like mailing yourself a phone book each time you want to look up a number), it just sends back the order—basically a list of instructions for how to rearrange what you already have. That way, you avoid clogging the pipes with a mountain of data.
// The worker owns the data; the main thread only ever ships commands and// receives an order. Returning the ORDER (not the rows) keeps the payload tiny.export function computeOrder(tracks: Track[], query: string, sort: Sort): Uint32Array { const ids = filterIds(tracks, query); if (sort) ids.sort((a, b) => compareBy(tracks, a, b, sort)); return Uint32Array.from(ids); // transfer this buffer back—zero copy}One caveat, and I think it’s the line between solving your problem and just copying what you saw on Stack Overflow. If your bottleneck is rendering—like trying to cram 150,000 rows into the DOM—a worker won’t save you. That’s a job for a virtualizer, which is a tool that only shows what’s on screen. Plotly, for example, needs the DOM to draw, so you can’t just shove Plotly itself into a worker and hope for the best. What you can do is move the heavy data prep out of the way, before the chart ever gets a look at it. The fix has to fit the bottleneck, not just look cute.
When not to reach for a worker
Sending work to a web worker isn’t free. You have to pack up your data, ship it over, and then unpack it on the other side—like mailing your lunch to work, so you don’t have to carry it. For small, quick jobs, that overhead can make things slower. The rule of thumb: offload work that’s big enough to blow the frame budget and doesn’t need the DOM—a long sort, a parse, an aggregation—and measure before and after so you know the postage was worth it. If you bolt a worker onto a tiny function or try to fix a rendering issue this way, you’re just paying a postage fee for no gain—except maybe food poisoning.
The mindset shift
Instead of asking, “How do I make this faster?” try, “Should this even be running on the same thread that draws my UI?” That question is inexpensive to ask up front and painful to retrofit later.
That’s the first win with a worker: you take one big, slow job and move it out of the way, so your UI doesn’t freeze up and your users don’t start clicking in frustration.
In Part 2, we’ll turn this into a proper architecture. Comlink helps you skip all the tedious message-passing code, OffscreenCanvas lets you move drawing off the main thread, and a streaming pipe can keep your UI fed with data straight from the firehose—without swamping the frame.