Pangram verdict · v3.3
We believe that this text is a mix of AI and human-written content.
AI likelihood · overall
MixedArticle text · 1,636 words · 2 segments analyzed
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 requestAnimationFrame callbacks - 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. The rendering pipeline for updating the screen 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. JS animationMain thread · rAFCSS animationCompositor · transformPress a button: the JS animation and typing freeze, but the CSS animation keeps spinning 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. Smoothness indicator (JS animation)60 fps 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.