What comes to mind when you hear “frontend optimization”? For most of us it’s things like reducing network requests, shrinking the bundle, or making good use of the cache. Beyond that, maybe cutting down on re-renders or tuning when resources get loaded. The main thread doesn’t usually come up, and there’s a reason for that: on most screens it never becomes a problem. But on screens with a lot of interaction, where data streams in live and scrolling, animation, and input all get tangled together, the picture changes. However much you save on network and bundle size, the screen freezes the moment the main thread gets blocked.
You’ve probably come across a website where scrolling stutters now and then, a button responds slightly late, or the letters you type into a search box show up half a beat behind. It isn’t bad enough to be annoying, but it gets on your nerves in a subtle way. That kind of jank is what a blocked main thread looks like.
When we run into jank like this as developers, the usual reaction is to wonder “is my code slow?” and start picking apart algorithms or looking for wasted computation. In most cases, though, the speed of the code is not the problem. The code isn’t slow. It just happens to be the code that’s holding the main thread.
The browser has a number of threads, but almost everything we can touch from code is concentrated on the main thread. Computation, rendering, event handling, network response handling, and your framework’s internals are all processed there. One resource, a mountain of work.
The browser’s main thread is expensive. Most of the time it doesn’t cause trouble, but once you try to do something ambitious, dealing with the main thread becomes the important part. This article is about how to handle that expensive resource.
What Does the Main Thread Do?
Let’s start with what the main thread actually does. Its work falls into two broad categories.
The first is running JavaScript. The code we write, along with event handlers, timers, network response callbacks, and the framework’s internals, all run here. These tasks execute in the order they enter the queue, whenever there is a gap, with no relation to the screen refresh cycle.
The second is drawing the screen. When the DOM or styles change and the screen needs updating, the browser goes through roughly these steps, in order, to produce a frame.
- Run
requestAnimationFramecallbacks - JavaScript registered to run just before the frame is drawn - Style calculation - compute the final CSS values for each element
- Layout - compute each element’s position and size (also called reflow)
- Paint - generate paint commands describing what to draw in which colors
If nothing changed, these steps are skipped entirely, so they don’t necessarily run every frame. Only the final compositing step, which takes the produced output and assembles it on screen, is handed off to the compositor thread1. In other words, most of the front half of the pipeline that draws the screen is the main thread’s responsibility.
For the screen to look smooth, frames have to be drawn at the display’s refresh rate. On the most common 60Hz display, that means 60 frames per second, or about 16.6 milliseconds per frame. And you don’t get to use all of it. Once the browser’s own processing cost is subtracted, the practical budget is usually considered to be around 10 milliseconds2, and on a 120Hz device the budget itself is cut in half.
The problem is that the two kinds of work above stand in a single line on the same thread. JavaScript was designed around a single-threaded event loop model. The main thread processes one task at a time, and while that task is running, nothing else can happen. If one JavaScript function runs for 200 milliseconds, then for those 200 milliseconds the browser can’t repaint the screen or receive a click from the user. Against a frame budget of around 10 milliseconds, that is a fatal amount of time. A task that runs this long and holds the main thread is called a long task, and anything over 50 milliseconds is generally considered a problem.
Words only go so far, so let’s feel it. In the demo below, pressing the button makes JavaScript grab the main thread for a moment.
When you press the button, the JS animation stops and typing into the input field does nothing. The CSS animation, on the other hand, keeps running. We’ll come back to where that difference comes from later. What to remember for now is that holding the main thread for a long time is the same thing as freezing the screen.
This connects directly to web performance metrics. INP (Interaction to Next Paint), which measures how long it takes for the screen to respond after the user does something, and TBT (Total Blocking Time), which measures the total time the main thread was blocked during page load, are both essentially ways of expressing how long the main thread was blocked. A large part of performance optimization is a matter of how carefully you spend this one thread.
The ways of spending it carefully fall into two broad families. One is to divide the main thread’s time well from within. The other is to send the work outside the main thread altogether. Let’s take them in order.
Using the Expensive Resource Wisely
The first family is about staying on the main thread but spending its time intelligently. There are four core moves.
- How do you split up work that runs too long?
- How do you group work that runs too often?
- Among several tasks, which goes first?
- How do you postpone work that doesn’t need to happen now?
We’ll call these splitting, batching, prioritizing, and deferring. The first two shape the size of tasks, and the last two decide their timing. Of the four, splitting is the foundation for the rest. Tasks need boundaries before you can decide what to slot in between them and what to push back. So we start with splitting.
Splitting
Picture the chat pane of a live stream. On a popular stream, chat can burst to hundreds of messages per second. In that environment, messages don’t arrive politely one at a time. When traffic spikes, the server sends them in clumps of dozens, and the moment you enter a room, hundreds of backlogged messages come down at once. What happens if you render that whole clump in one go right when it arrives? Every message you draw brings DOM creation, style calculation, layout, and paint along with it, and those hundreds of iterations run back to back inside a single task. Meanwhile, the user trying to type their own message gets a stuttering input field, and every other animation on screen hitches too. Other people’s chat is monopolizing the main thread and getting in the way of yours.
The fix is what we said above. Cut the clump into small pieces, and between the pieces, hand control of the main thread back for a moment. In those gaps the browser can catch up on the screen updates and input handling it had queued.
The demo below simulates a streaming chat pane. Press “Flood the chat” and messages start pouring in. Try typing in the input field while watching the smoothness gauge and fps at the top, and compare the “Immediate render” and “Yielding render” modes.
In “Immediate render” mode, the DOM is touched as each message arrives, so while chat is flooding in, fps drops sharply, the gauge stutters, and the input field lags. If you look closely, the chat messages themselves start appearing noticeably more slowly as well, because the callback that receives and processes them is also a task waiting in the main thread’s line, so it gets delayed with everything else. Now switch to “Yielding render”. Messages are still drawn one at a time, just as before, yet input comes back to life and the screen moves again. The only thing that changed is that after every 20 messages, the main thread is released for a moment.
One thing not to misread here is that yielding does not make the work faster. The total amount of work is unchanged, and the few milliseconds spent waiting at each yield are added overhead, so in wall-clock terms it actually takes longer. So why did rendering recover along with input?
As we saw earlier, the main thread can do nothing while a task is running. The rendering pipeline that produces frames can’t cut into the middle of a task either. It can only run between tasks. Yielding is the act of creating those gaps. The backlogged input and frame production get their turn in the gaps, and to the user it feels as though performance improved.
At the code level, the classic way to yield is setTimeout, which pushes the continuation into the next task. Take a look at the following code.
// A batch of chat messages arrives at once
socket.on('messages', (chats) => {
renderChats(chats);
});
// Draw the messages, yielding the main thread after every 20
async function renderChats(chats) {
let count = 0;
for (const chat of chats) {
appendChatNode(chat); // draw one message
if (++count % 20 === 0) {
await new Promise((resolve) => setTimeout(resolve, 0)); // yield here
}
}
}
With this in place, no matter how hard chat floods in, the DOM work never occupies the main thread in one piece, and between the pieces there is room for the user’s input and animations to be processed.
The star of this code is setTimeout. When it schedules the resumption of the remaining work as a new task, the current task ends right there, and in that gap the backlogged input and rendering get processed. await pauses the function until the scheduled task comes back around, then picks up where it left off.
The example above split the incoming work by count. But if an animation is already running, or the user is in the middle of scrolling, splitting by time is safer than splitting by count. An animation uses a little of the main thread every frame, so a heavy job has to keep checking the clock and cut itself off before it swallows what’s left of the frame’s budget.
async function processDuringAnimation(items) {
let i = 0;
let frameStart = performance.now();
while (i < items.length) {
// Work only until 5ms have passed since the frame started
while (i < items.length && performance.now() - frameStart < 5) {
doWork(items[i++]);
}
frameStart = await new Promise(requestAnimationFrame); // resume with the next frame's start time
}
}
Here performance.now() acts as the stopwatch that checks whether we’ve gone over budget, and requestAnimationFrame acts as the alarm that says “wake me just before the next frame is drawn.” This is also why we yield with rAF rather than setTimeout when splitting by time. The resumption lands in step with the frame cycle.
Note that rAF passes the frame’s start timestamp to its callback, and the code above uses that as the reference point for the budget. The reason is that the function doesn’t have the frame to itself. If other animation callbacks ran earlier in the same frame, our share has to shrink by however much time they used, or the frame budget is broken. Anchoring to the frame’s start time turns “use 5ms” into “use until 5ms after the frame started,” which makes the code cooperate naturally when several animations share one frame.
Why 5 milliseconds? There’s nothing special about the number. We said the practical budget is around 10 milliseconds, so handing roughly half to background work and leaving the rest for animation callbacks, style, layout, and paint is a reasonable heuristic. If your animations are heavy, shrink it.
With this approach, even while heavy work is in progress, there is room to draw the screen every frame, and the work and the animation run smoothly side by side. Try the demo below. Moving the mouse scatters 4,000 particles away from the cursor, and nearby particles also push each other apart, so deciding one particle’s direction means checking its distance to every other particle. That comes to roughly 16 million distance calculations per pass, and recomputing all of it every frame blows through the frame budget on its own. Compare the “Compute all at once” and “5ms per frame” modes.
Splitting is the most basic way to use the main thread’s time sparingly. It is what gives users the perceived performance they care about: fast responses and a smooth screen.
Finally, a few points to be careful about. First, splitting too finely backfires. Yielding and coming back has a cost of its own, so if the pieces are too small, that overhead can end up larger than the work you’re trying to do.
Second, yielding with setTimeout involves a minimum delay3, so each piece can end up waiting a few milliseconds for nothing. Usually this doesn’t matter, but in situations that demand a very high level of responsiveness, the delay can become a problem.
That’s why some code schedules the next task by posting a message through a MessageChannel instead. React’s scheduler uses this method. More recently, a standard API called scheduler.yield() has also appeared to address this problem. Its advantage is that after yielding, the original work resumes ahead of other queued tasks instead of being pushed to the back. Browser support is still uneven, though.
Third, different yielding tools come back at different times. setTimeout and scheduler.yield() resume without regard to the rendering cycle, while requestAnimationFrame resumes just before a frame is drawn, so for work that needs to keep in rhythm with screen updates, requestAnimationFrame is the better fit. If you want finer control over priorities, you can also build your own queue on MessageChannel and manage the yielding and resuming yourself.
Lastly, splitting isn’t always possible. Parsing a multi-megabyte response with JSON.parse, for example, is a single atomic synchronous call, and there is no way to stop halfway and yield. Until the parse finishes, the main thread is stuck. Heavy work that can’t be split like this is the clear limit of “using it wisely.” In that case you have to change the premise and not do the work on the main thread at all. We’ll get to that in “Not Using the Expensive Resource.”
Batching
Splitting on its own doesn’t solve every problem, though. Think back to the streaming chat example. Yielding rescued input and rendering, but it did nothing to make chat draw faster. If anything, throughput, meaning the number of messages drawn per unit of time, went down by the overhead of yielding. So what happens if chat pours in faster than the throughput? Arrivals outpace processing, the backlog keeps growing, and the messages reaching the screen get older and older. This situation is called backpressure.
Raising throughput takes a different tool than splitting. For example, instead of drawing messages one by one, you can draw the accumulated batch in one go. The fixed per-message cost folds together, and the same amount of time renders more chat. Being told to split and then told to batch may sound like a contradiction, but the point of both is to trim tasks to an appropriate size. Splitting deals with tasks so long that rendering can’t squeeze in, and batching deals with tasks so frequent that the pipeline’s fixed cost is paid over and over.
The best batching targets are events. Scroll, resize, and input events can fire dozens or hundreds of times in a short span. If you run a heavy handler on every one of them, there’s nothing left of the main thread. So we collapse many events into one execution, either by “running once after things quiet down” or by “running at most once per interval.” These are called debounce and throttle, respectively.
The demo below is a markdown editor with a long CHANGELOG open. Building the preview means parsing the entire document (about 2,000 lines) and rebuilding its DOM from scratch, which is far too expensive to run on every keystroke. Type quickly into the left editor with “No debounce” selected. The preview is rebuilt once per character and your input falls behind. Switch to “Debounce 300ms” and the render happens just once, after you stop typing, and the typing becomes smooth.
For visual updates, you can use requestAnimationFrame. The screen only gets drawn once per frame anyway, so no matter how many update requests pile up, drawing once per frame is enough.
let scheduled = false;
socket.on('tick', (tick) => {
chart.push(tick); // keep every data point — nothing is thrown away
if (scheduled) return; // this frame's draw is already booked
scheduled = true;
requestAnimationFrame(() => {
renderBoard(); // draw once per frame
scheduled = false;
});
});
The demo below updates a board of 60 tickers with over 1,000 messages per second. “Render every tick” mode redraws the whole board on every message. Calling a chart library’s update() on every message is a common mistake, and this is exactly what it looks like. Switch to “Once per frame” and every arriving data point is still reflected, but the fps comes back.
DOM writes can be batched as well. Appending a hundred nodes in one operation instead of one at a time, or toggling a single class instead of changing style properties individually, turns many changes into one and helps performance. The old technique of assembling an HTML string and assigning it to innerHTML in one shot has the same essence. You gather the writes so the rendering pipeline’s fixed cost is paid once.
This kind of optimization is a familiar pattern to frontend developers, and React’s virtual DOM is itself a device for it. However many times state changes, the changes accumulate in the virtual tree, get compared first, and only the actual differences are applied to the real DOM in one pass. Merging several state updates inside one event handler into a single re-render, or queueing analytics events and sending them in one request instead of individually, is the same idea. A fixed cost that would repeat once per item gets paid once per batch.
Prioritizing
If splitting and batching shape the size of work, prioritizing decides its order. Reacting to the button the user just pressed needs to happen quickly, while precomputing statistics for content that’s off screen can wait. Prioritizing means ordering the urgent work ahead of the work that isn’t urgent.
Order matters because on a main thread that nothing can interrupt, order is the responsiveness the user feels. To control order, you usually build a queue that work is pushed into and pulled out of. Jobs in the queue are processed FIFO, but when something urgent comes in, it gets pulled to the front of the line.
const queue = [];
const channel = new MessageChannel();
// One message = one task. Process a piece, then book the next one
channel.port1.onmessage = () => {
const job = queue.shift(); // take whatever is at the front right now
if (!job) return; // guard against duplicate bookings
job();
if (queue.length > 0) channel.port2.postMessage(null);
};
function postJob(job, urgent = false) {
if (urgent) queue.unshift(job); // urgent jobs cut to the front
else queue.push(job);
if (queue.length === 1) channel.port2.postMessage(null);
}
This structure is useful because priority isn’t a fixed value. Work that wasn’t urgent to begin with can suddenly become urgent because of something the user does. Say the user attaches a few dozen photos to a post. To save on costs, the client sometimes resizes images before uploading them to the server, and that resizing is unhurried work that can be processed in order.
React works along similar lines, with more sophisticated machinery on top, such as starvation protection, batching, and continuations. Use
startTransitionoruseDeferredValueand a scheduler spins up inside that yields viaMessageChanneland orders work with its own priority queue.
But once the user clicks a particular photo to check that it attached properly, that photo’s preview becomes the most urgent job there is. With a priority queue like the one above, the urgent job can be handled first. This approach, where you get ahead on the work while idle and then rush it when it becomes needed, is sometimes called the idle-until-urgent pattern4.
// Build previews for the attached photos, in order
files.forEach((file, i) => {
const job = () => createPreview(file, i);
job.photoId = i; // tag it so we can find it in the queue later
postJob(job);
});
// Clicking a photo that isn't ready pulls its job to the front → priority bump
onClickPhoto((i) => {
const idx = queue.findIndex((job) => job.photoId === i);
if (idx > 0) queue.unshift(queue.splice(idx, 1)[0]);
});
Feel the difference in the demo below. Sixty photos have been attached, and each preview is actually generated with per-pixel filtering. While the previews are being built in order, click one of the gray tiles that isn’t ready yet. In “In order” mode you have to wait until that tile’s turn comes, but in “Clicked first” mode it skips the queue and fills in right away. The total amount of work is the same and only the order changed, yet the experience for the user is completely different.
Priority, then, is a matter of working out what matters most to the user at this particular moment.
Modern browsers offer standards for this, such as the Scheduler API and TaskController. Support is still incomplete, so in practice people pair them with polyfills or build their own queues. This article uses the hand-built-queue approach.
Deferring
The last and most reliable way to conserve the main thread is to not do now what doesn’t need to be done now. Where splitting and batching ask “at what size” and prioritizing asks “in what order,” deferring asks whether this work really has to happen right now at all.
Initial page load is the classic place where deferring pays off. There’s no need to download and execute all of your JavaScript up front. With code splitting, only the code the current screen needs runs first, and the rest is loaded when it becomes necessary, which keeps the main thread from slowing down right from the start.
You can defer rendering itself, too. Think of a social feed. Some apps freeze for a moment when you come back from the notifications tab after scrolling far enough to accumulate hundreds of posts. Even if the feed’s DOM is kept alive while switching tabs, when it becomes visible again the browser recomputes style and layout for all several hundred posts at once, including the ones nowhere near the viewport. So what if off-screen posts were left as empty shells that only take up their height, and got filled with real content as they approach the screen? The tool that tells you about that “approaching” moment is IntersectionObserver. It’s the same method image lazy-loading libraries use.
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) fill(entry.target); // fill as it approaches
else empty(entry.target); // empty it when it leaves, keeping its place
}
},
{ rootMargin: '400px' } // headroom to fill before the scroll arrives
);
feed.querySelectorAll('.feed-item').forEach((el) => io.observe(el));
The demo below is a feed with 1,500 posts piled up5. It starts with “Render only when visible” turned on. Visit the notifications tab and come back, and the return is instant regardless of how much has accumulated. Now switch to “Render everything” and make the round trip again. Every return freezes for hundreds of milliseconds while all 1,500 posts are laid out again. Apart from unfilled slots showing briefly during fast scrolling, the two modes look the same.
Rendering isn’t the only thing this approach defers. Off-screen posts never get their DOM built at all, so the cost of creating and maintaining it is deferred along with everything else. If a widget carries heavy initialization, that initialization can also wait until the widget nears the screen. There’s also a CSS property, content-visibility: auto, that aims for a similar effect in a single line. As of this writing, though, implementations vary between engines, and Safari has a performance bug that makes returning to the page slower rather than faster, so for now IntersectionObserver is the option that behaves predictably everywhere.
Continuously running work, like carousels, animated promo banners, and live charts, is pure waste while off screen. You’re spending main-thread time every frame to redraw a picture nobody can see. Run it while it’s visible and stop it when it leaves. That’s also how the dozen-plus demos in this article manage to coexist on a single page. Each one is built to stop once it scrolls out of view.
Not Using the Expensive Resource
Everything so far was about using the main thread, but using it carefully. The second family of techniques is about not doing the work on the main thread in the first place.
Let’s return to the question left hanging in the long-task demo. The main thread was completely blocked, so why did the CSS animation keep running as if nothing had happened? The answer is that the animation was never running on the main thread to begin with. Inside the browser, several threads divide up the work. These are the notable ones.
- Main thread: runs JavaScript, manipulates the DOM, calculates styles, performs layout, handles events.
- Compositor thread: composites already-drawn layers onto the screen. Handles scrolling and certain animations.
- Raster threads: turn paint commands into actual pixels.
- Worker threads: separate JavaScript execution spaces that we create explicitly.
Unfortunately, we can’t control these threads however we like. The compositor and raster threads are territory the browser manages on its own, so we can’t give them direct orders, and worker threads, which we can create, come with the major restriction of having no DOM access.
So “not using” the main thread doesn’t mean sending arbitrary work elsewhere. It means picking out the work that can take a form other threads can handle, and sending that. There are two main ways to do it.
Moving Work to the Compositor
The compositor thread is the reason the CSS animation didn’t stop. transform and opacity don’t change an element’s position, size, or color in the document. They move an already-painted layer or adjust its transparency, so there’s no need to redo layout or paint. That lets the browser handle them directly on the compositor thread without going through the main thread at all. Even when the main thread is busy, the compositor runs separately, so the animation stays smooth.
If you move an element with properties like top, left, width, or height instead, layout has to be recomputed every frame, and that is main-thread work. In the demo below, the two boxes slide side to side in the same way, but one moves with transform and the other with left. Press the button to put load on the main thread.
Once the main thread gets busy, only the bottom box, the one moving with left, starts to stutter. The transform box at the top is being driven by the compositor and stays smooth whatever the load. The same “slide sideways” ends up on a completely different thread depending on which property you animate. That’s why it’s better for performance to build animations that move things with transform: translate rather than left, and animations that resize things with transform: scale rather than width.
But what about animations where the layout genuinely has to change? Picture a list where deleting an item makes the items below it slide smoothly up into place. This isn’t decorative motion. The positions really do change. Yet animating top means layout on every frame. The technique that resolves this dilemma is FLIP (First, Last, Invert, Play)6. In short, you cause exactly one layout change and leave the entire movement to transform. It goes in this order.
- First: measure the position before the move
- Last: actually change the layout and measure the new position. Layout happens exactly once, here
- Invert: apply a transform to the element in its new position so it appears to still be in the old one
- Play: animate that transform away. This part belongs to the compositor
const first = el.getBoundingClientRect(); // First: where it is now
list.prepend(el); // the one and only layout change
const last = el.getBoundingClientRect(); // Last: where it ended up
const dx = first.left - last.left;
const dy = first.top - last.top;
// Invert: make it look like it's back at the old position → Play: release it
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
duration: 300,
easing: 'ease-in-out',
});
To the user’s eye, the element glides from its old spot to its new one, but in reality the element has already arrived at its new spot, and the transform briefly drags it back before releasing it into place. While the animation plays, the only per-frame work is the compositor interpolating a transform. Most list-reordering animations are built this way, and Vue’s TransitionGroup and Framer Motion’s layout animations are FLIP under the hood.
The demo below shows the difference at a glance. Press “Play rank shuffle” and both ranking lists reshuffle in the same way. The left list animates top with a transition, and the right list moves only transform, via FLIP. With no load, both look smooth. Now turn on “Load the main thread” and play it again. The left list stutters its way to the finish, while the right one stays smooth even under load.
Finally, two things worth knowing before handing work to the compositor. One is will-change: transform. It gives the browser a hint that “this element is about to change, so prepare it as its own layer in advance,” which can smooth out the start of an animation. Overuse it, though, and the number of layers balloons and memory gets wasted instead.
The other is reading layout values, which you just saw in the FLIP code. If you get the ordering wrong between code that reads layout values (getBoundingClientRect, offsetWidth) and code that writes styles, you run into a problem called layout thrashing.
// 🔴 Reads and writes interleaved — forces a layout recalculation every iteration
for (const el of elements) {
const width = el.offsetWidth; // read (needs layout)
el.style.width = width + 10 + 'px'; // write (invalidates layout)
}
// 🟢 Finish all the reads, then do the writes together
const widths = elements.map((el) => el.offsetWidth); // gather reads
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px'; // gather writes
});
If you read a layout value right after changing one, the browser has no choice but to recompute layout on the spot to give you an up-to-date answer. When that happens inside a loop, layout runs dozens of times in a single frame and the main thread slows down. Simply getting into the habit of grouping reads with reads and writes with writes is enough to avoid it.
Sending Work to a Worker
Then what about heavy work that can’t be re-expressed with transform? What do you do with things like parsing a large payload, processing images, or running complex computation? Pure calculation like that can be sent outside the main thread in its entirety with a web worker.
A worker runs JavaScript on a separate thread, fully separated from the main one. Hand the heavy computation to a worker, and in the meantime the main thread can concentrate solely on keeping the UI responsive.
// Main thread
const worker = new Worker('parser.js');
worker.postMessage(hugeRawData);
worker.onmessage = (e) => {
render(e.data); // receive only the result and put it on screen
};
It isn’t free, of course. As we saw above, workers can’t access the DOM, so they can’t touch the screen directly. They can only compute and then send the results back to the main thread. And the main thread and the worker communicate only through postMessage, which copies (serializes) the data, so when the data being passed around is large, that cost is considerable.
So workers aren’t a cure-all. They shine when the computation is heavy enough to outweigh the communication cost and has nothing to do with the DOM. If you send a short, light job to a worker, the communication cost ends up larger than the computation cost and you come out behind. The key is to keep asking, every time, whether this work really needs to run on the main thread.
Since words only go so far, let’s bring in some genuinely heavy image processing. Seam carving is an algorithm that finds the vertical path of lowest energy (least color change) through a photo and removes it one path at a time, narrowing the image while preserving the important subject7. Removing a single seam means sweeping through hundreds of thousands of pixels, so removing 250 or so seams adds up to hundreds of millions of operations. Run that on the main thread and the whole page will freeze. Send it to a worker, though, and the screen can stay responsive the entire time the computation is running.
Press “Run on main thread” in the demo below. For the one or two seconds the computation runs, the entire page freezes, and the result appears all at once only after it’s finished. Because there are no task boundaries for paint to slip into, you couldn’t show the intermediate steps even if you wanted to. Now switch to “Run in worker”. While the same computation runs, you get to watch the image narrow in real time.
There is one more thing behind those smooth intermediate frames. If the worker copied a multi-megabyte pixel buffer every time it sent a frame, that cost would add up too. So postMessage offers an alternative to copying the data: transferring ownership of it outright. Transferable objects like ArrayBuffer move by reference only, so the cost is close to zero regardless of size. The side that hands the buffer over can no longer use it, and in exchange the copy cost disappears.
// Hand the pixel buffer to the worker without copying
// After the transfer, this side can no longer use it
worker.postMessage({ buf: pixels.buffer, width, height }, [pixels.buffer]);
Eliminating the Work Itself
So far we’ve been asking whether a piece of work really needs to run on the main thread. This time, let’s ask whether the work needs to happen at all. The best thing for performance is not doing the work in the first place.
Backpressure came up earlier. Batch as well as you like, and once the inflow exceeds the maximum throughput the backlog still grows without limit. Unfortunately, the browser has no good way to tell the server to slow down. At some point you have to give up on the idea of doing everything you’re given. There are generally three ways to eliminate work.
The first is dropping. For data that just flows past, like live logs, once processing starts falling behind you can quietly discard the oldest entries and users won’t notice. Keeping up with the present matters more than showing everything.
The second is merging. For data where only the latest value means anything, like rankings, you can merge the backlogged updates and apply only the final value. With merging, the amount of work is pinned to what the screen can digest, no matter how fast the inflow gets.
The third, skipping, targets repeated work rather than incoming work. If a computation gives the same result for the same input, there’s no reason to do it a second time. Remembering results and reusing them is called memoization.
This idea has been hiding throughout the article. Debounce skipped executions during typing, and the feed demo skipped rendering posts that weren’t visible. Looked at from this angle, half of this article was about eliminating work.
When you study optimization, your attention tends to go to ways of doing work well, but the biggest gains usually come from removing work. Before making some task faster, think about it first. Does this work have to happen, now, here, at all?
Closing
Some developers think of frontend work as the easy kind. But the browser is a far more complex system than we tend to assume. Drawing screens with HTML, CSS, and JavaScript is not the whole story.
Apps that deal with a flood of real-time data, like streaming platforms, or whose screens never stop changing, like image editors, maps, and games, start to feel slow whenever the main thread gets busy. Not everyone is on the latest hardware, so for services like these, optimization is essential. And solving these problems takes more than optimizing code. It takes understanding how the browser works, spending the main thread’s time sparingly, and not doing work that doesn’t need doing at all.
In the end, nothing is easy once you dig deep enough. So much of development is trade-offs, and you have to choose according to the situation, which ultimately comes down to the developer’s experience and judgment. Neither is built quickly, but both can certainly be built through study and experiment. I hope this article helps a little along the way.
-
The compositor thread is responsible for compositing already-drawn layers onto the screen. We’ll come back to it later in the article. ↩
-
The HTML spec mandates a minimum 4-millisecond delay once
setTimeoutcalls nest more than 5 levels deep. Yielding repeatedly inside a loop, as in the code above, trips this condition almost immediately, so even with the delay set to 0, each piece waits at least 4 milliseconds. ↩ -
Granted, 1,500 posts rarely pile up on one page in practice. The demo stacks that many on purpose, to make the effect of deferred rendering dramatic. ↩