{
    "archive_path": "archive/1788430023.255183",
    "base_url": "kciter.so/posts/the-expensive-main-thread/en",
    "basename": "",
    "bookmarked_date": "2026-09-03 10:07",
    "canonical": {
        "archive_org_path": "https://web.archive.org/web/kciter.so/posts/the-expensive-main-thread/en",
        "dom_path": "output.html",
        "favicon_path": "favicon.ico",
        "git_path": "git/",
        "google_favicon_path": "https://www.google.com/s2/favicons?domain=kciter.so",
        "headers_path": "headers.json",
        "htmltotext_path": "htmltotext.txt",
        "index_path": "index.html",
        "media_path": "media/",
        "mercury_path": "mercury/content.html",
        "pdf_path": "output.pdf",
        "readability_path": "readability/content.html",
        "screenshot_path": "screenshot.png",
        "singlefile_path": "singlefile.html",
        "warc_path": "warc/",
        "wget_path": "kciter.so/posts/the-expensive-main-thread/en/index.html"
    },
    "domain": "kciter.so",
    "extension": "",
    "hash": "1M47YVECZ9RTRCAKK8E4",
    "history": {
        "archive_org": [],
        "dom": [],
        "favicon": [
            {
                "cmd": [
                    "curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--max-time",
                    "120",
                    "--output",
                    "favicon.ico",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/0.7.4 (+https://github.com/ArchiveBox/ArchiveBox/) curl/curl 8.14.1 (x86_64-pc-linux-gnu)",
                    "https://www.google.com/s2/favicons?domain=kciter.so"
                ],
                "cmd_version": "curl 8.14.1 (x86_64-pc-linux-gnu)",
                "end_ts": "2026-09-03T10:11:33.430581+00:00",
                "index_texts": null,
                "output": "favicon.ico",
                "pwd": "/data/archive/1788430023.255183",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-03T10:11:33.013713+00:00",
                "status": "succeeded"
            }
        ],
        "git": [],
        "headers": [
            {
                "cmd": [
                    "curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--head",
                    "--max-time",
                    "120",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/0.7.4 (+https://github.com/ArchiveBox/ArchiveBox/) curl/curl 8.14.1 (x86_64-pc-linux-gnu)",
                    "https://kciter.so/posts/the-expensive-main-thread/en/"
                ],
                "cmd_version": "curl 8.14.1 (x86_64-pc-linux-gnu)",
                "end_ts": "2026-09-03T10:11:33.635163+00:00",
                "index_texts": null,
                "output": "headers.json",
                "pwd": "/data/archive/1788430023.255183",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-03T10:11:33.436450+00:00",
                "status": "succeeded"
            }
        ],
        "htmltotext": [],
        "media": [],
        "mercury": [
            {
                "cmd": [
                    "/app/node_modules/@postlight/parser/cli.js",
                    "https://kciter.so/posts/the-expensive-main-thread/en/"
                ],
                "cmd_version": "1.0.0",
                "end_ts": "2026-09-03T10:11:51.536264+00:00",
                "index_texts": null,
                "output": "mercury",
                "pwd": "/data/archive/1788430023.255183",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-03T10:11:47.616810+00:00",
                "status": "succeeded"
            }
        ],
        "pdf": [],
        "readability": [
            {
                "cmd": [
                    "/app/node_modules/readability-extractor/readability-extractor",
                    "/tmp/tmp9f1phk_x",
                    "https://kciter.so/posts/the-expensive-main-thread/en/"
                ],
                "cmd_version": "0.0.11",
                "end_ts": "2026-09-03T10:11:47.610093+00:00",
                "index_texts": [
                    "What comes to mind when you hear \u201cfrontend optimization\u201d? For most of us it\u2019s 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\u2019t usually come up, and there\u2019s 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.\nYou\u2019ve 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\u2019t 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.\nWhen we run into jank like this as developers, the usual reaction is to wonder \u201cis my code slow?\u201d 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\u2019t slow. It just happens to be the code that\u2019s holding the main thread.\nThe 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\u2019s internals are all processed there. One resource, a mountain of work.\nThe browser\u2019s main thread is expensive. Most of the time it doesn\u2019t 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.\nWhat Does the Main Thread Do?\nLet\u2019s start with what the main thread actually does. Its work falls into two broad categories.\nThe first is running JavaScript. The code we write, along with event handlers, timers, network response callbacks, and the framework\u2019s 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.\nThe 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.\n\nRun requestAnimationFrame callbacks - JavaScript registered to run just before the frame is drawn\nStyle calculation - compute the final CSS values for each element\nLayout - compute each element\u2019s position and size (also called reflow)\nPaint - generate paint commands describing what to draw in which colors\n\nIf nothing changed, these steps are skipped entirely, so they don\u2019t 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\u2019s responsibility.\nThe rendering pipeline for updating the screen\nFor the screen to look smooth, frames have to be drawn at the display\u2019s refresh rate. On the most common 60Hz display, that means 60 frames per second, or about 16.6 milliseconds per frame. And you don\u2019t get to use all of it. Once the browser\u2019s 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.\nThe 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\u2019t 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.\nWords only go so far, so let\u2019s feel it. In the demo below, pressing the button makes JavaScript grab the main thread for a moment.\nJS animationMain thread \u00b7 rAFCSS animationCompositor \u00b7 transformPress a button: the JS animation and typing freeze, but the CSS animation keeps spinning\nWhen 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\u2019ll 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.\nThis 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.\nThe ways of spending it carefully fall into two broad families. One is to divide the main thread\u2019s time well from within. The other is to send the work outside the main thread altogether. Let\u2019s take them in order.\nUsing the Expensive Resource Wisely\nThe first family is about staying on the main thread but spending its time intelligently. There are four core moves.\n\nHow do you split up work that runs too long?\nHow do you group work that runs too often?\nAmong several tasks, which goes first?\nHow do you postpone work that doesn\u2019t need to happen now?\n\nWe\u2019ll 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.\nSplitting\nPicture 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\u2019t 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\u2019s chat is monopolizing the main thread and getting in the way of yours.\nThe 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.\nThe demo below simulates a streaming chat pane. Press \u201cFlood the chat\u201d and messages start pouring in. Try typing in the input field while watching the smoothness gauge and fps at the top, and compare the \u201cImmediate render\u201d and \u201cYielding render\u201d modes.\nSmoothness indicator (JS animation)60 fps\nIn \u201cImmediate render\u201d 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\u2019s line, so it gets delayed with everything else. Now switch to \u201cYielding render\u201d. 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.\nOne 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?\nAs we saw earlier, the main thread can do nothing while a task is running. The rendering pipeline that produces frames can\u2019t 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.\nAt 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.\n// A batch of chat messages arrives at once\nsocket.on('messages', (chats) => {\n  renderChats(chats);\n});\n\n// Draw the messages, yielding the main thread after every 20\nasync function renderChats(chats) {\n  let count = 0;\n  for (const chat of chats) {\n    appendChatNode(chat); // draw one message\n\n    if (++count % 20 === 0) {\n      await new Promise((resolve) => setTimeout(resolve, 0)); // yield here\n    }\n  }\n}\nWith 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\u2019s input and animations to be processed.\nThe 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.\nHow yielding changes the timeline\nThe 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\u2019s left of the frame\u2019s budget.\nasync function processDuringAnimation(items) {\n  let i = 0;\n  let frameStart = performance.now();\n  while (i < items.length) {\n    // Work only until 5ms have passed since the frame started\n    while (i < items.length && performance.now() - frameStart < 5) {\n      doWork(items[i++]);\n    }\n    frameStart = await new Promise(requestAnimationFrame); // resume with the next frame's start time\n  }\n}\nHere performance.now() acts as the stopwatch that checks whether we\u2019ve gone over budget, and requestAnimationFrame acts as the alarm that says \u201cwake me just before the next frame is drawn.\u201d This is also why we yield with rAF rather than setTimeout when splitting by time. The resumption lands in step with the frame cycle.\nNote that rAF passes the frame\u2019s 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\u2019t 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\u2019s start time turns \u201cuse 5ms\u201d into \u201cuse until 5ms after the frame started,\u201d which makes the code cooperate naturally when several animations share one frame.\n\nWhy 5 milliseconds? There\u2019s 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.\n\nWith 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\u2019s 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 \u201cCompute all at once\u201d and \u201c5ms per frame\u201d modes.\n4,000 particles \u00b7 steering cost this frame 0.0ms60 fpsMove your mouse or finger here and the particles scatter away from the cursor\nSplitting is the most basic way to use the main thread\u2019s time sparingly. It is what gives users the perceived performance they care about: fast responses and a smooth screen.\nFinally, 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\u2019re trying to do.\nSecond, yielding with setTimeout involves a minimum delay3, so each piece can end up waiting a few milliseconds for nothing. Usually this doesn\u2019t matter, but in situations that demand a very high level of responsiveness, the delay can become a problem.\nThat\u2019s why some code schedules the next task by posting a message through a MessageChannel instead. React\u2019s 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.\nThird, 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.\nLastly, splitting isn\u2019t 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\u2019t be split like this is the clear limit of \u201cusing it wisely.\u201d In that case you have to change the premise and not do the work on the main thread at all. We\u2019ll get to that in \u201cNot Using the Expensive Resource.\u201d\nBatching\nSplitting on its own doesn\u2019t 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.\nRaising 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\u2019t squeeze in, and batching deals with tasks so frequent that the pipeline\u2019s fixed cost is paid over and over.\nThe 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\u2019s nothing left of the main thread. So we collapse many events into one execution, either by \u201crunning once after things quiet down\u201d or by \u201crunning at most once per interval.\u201d These are called debounce and throttle, respectively.\nThe 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 \u201cNo debounce\u201d selected. The preview is rebuilt once per character and your input falls behind. Switch to \u201cDebounce 300ms\u201d and the render happens just once, after you stop typing, and the typing becomes smooth.\nA ~2,000-line CHANGELOG.md60 fps\nFor 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.\nlet scheduled = false;\n\nsocket.on('tick', (tick) => {\n  chart.push(tick); // keep every data point \u2014 nothing is thrown away\n  if (scheduled) return; // this frame's draw is already booked\n  scheduled = true;\n  requestAnimationFrame(() => {\n    renderBoard(); // draw once per frame\n    scheduled = false;\n  });\n});\nThe demo below updates a board of 60 tickers with over 1,000 messages per second. \u201cRender every tick\u201d mode redraws the whole board on every message. Calling a chart library\u2019s update() on every message is a common mistake, and this is exactly what it looks like. Switch to \u201cOnce per frame\u201d and every arriving data point is still reflected, but the fps comes back.\nSmoothness meter (JS animation)60 fps\nDOM 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\u2019s fixed cost is paid once.\nThis kind of optimization is a familiar pattern to frontend developers, and React\u2019s 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.\nPrioritizing\nIf 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\u2019s off screen can wait. Prioritizing means ordering the urgent work ahead of the work that isn\u2019t urgent.\nOrder 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.\nconst queue = [];\nconst channel = new MessageChannel();\n\n// One message = one task. Process a piece, then book the next one\nchannel.port1.onmessage = () => {\n  const job = queue.shift(); // take whatever is at the front right now\n  if (!job) return; // guard against duplicate bookings\n  job();\n  if (queue.length > 0) channel.port2.postMessage(null);\n};\n\nfunction postJob(job, urgent = false) {\n  if (urgent) queue.unshift(job); // urgent jobs cut to the front\n  else queue.push(job);\n  if (queue.length === 1) channel.port2.postMessage(null);\n}\nThis structure is useful because priority isn\u2019t a fixed value. Work that wasn\u2019t 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.\n\nReact works along similar lines, with more sophisticated machinery on top, such as starvation protection, batching, and continuations. Use startTransition or useDeferredValue and a scheduler spins up inside that yields via MessageChannel and orders work with its own priority queue.\n\nBut once the user clicks a particular photo to check that it attached properly, that photo\u2019s 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.\n// Build previews for the attached photos, in order\nfiles.forEach((file, i) => {\n  const job = () => createPreview(file, i);\n  job.photoId = i; // tag it so we can find it in the queue later\n  postJob(job);\n});\n\n// Clicking a photo that isn't ready pulls its job to the front \u2192 priority bump\nonClickPhoto((i) => {\n  const idx = queue.findIndex((job) => job.photoId === i);\n  if (idx > 0) queue.unshift(queue.splice(idx, 1)[0]);\n});\nFeel 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\u2019t ready yet. In \u201cIn order\u201d mode you have to wait until that tile\u2019s turn comes, but in \u201cClicked first\u201d 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.\nBuilding previews for the 60 attached photos, one at a time\nPriority, then, is a matter of working out what matters most to the user at this particular moment.\n\nModern 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.\n\nDeferring\nThe last and most reliable way to conserve the main thread is to not do now what doesn\u2019t need to be done now. Where splitting and batching ask \u201cat what size\u201d and prioritizing asks \u201cin what order,\u201d deferring asks whether this work really has to happen right now at all.\nInitial page load is the classic place where deferring pays off. There\u2019s 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.\nYou 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\u2019s 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 \u201capproaching\u201d moment is IntersectionObserver. It\u2019s the same method image lazy-loading libraries use.\nconst io = new IntersectionObserver(\n  (entries) => {\n    for (const entry of entries) {\n      if (entry.isIntersecting) fill(entry.target); // fill as it approaches\n      else empty(entry.target); // empty it when it leaves, keeping its place\n    }\n  },\n  { rootMargin: '400px' } // headroom to fill before the scroll arrives\n);\n\nfeed.querySelectorAll('.feed-item').forEach((el) => io.observe(el));\nThe demo below is a feed with 1,500 posts piled up5. It starts with \u201cRender only when visible\u201d turned on. Visit the notifications tab and come back, and the return is instant regardless of how much has accumulated. Now switch to \u201cRender everything\u201d 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.\nA feed with 1,500 posts piled up \u2014 try visiting the notifications tab60 fpsTime to get back to the feed\u2212Render only when visible: IntersectionObserver fills only the posts near the viewport \u2014 returning is fast no matter how much has piled up\nRendering isn\u2019t 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\u2019s 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.\nContinuously running work, like carousels, animated promo banners, and live charts, is pure waste while off screen. You\u2019re spending main-thread time every frame to redraw a picture nobody can see. Run it while it\u2019s visible and stop it when it leaves. That\u2019s 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.\nNot Using the Expensive Resource\nEverything 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.\nLet\u2019s 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.\n\nMain thread: runs JavaScript, manipulates the DOM, calculates styles, performs layout, handles events.\nCompositor thread: composites already-drawn layers onto the screen. Handles scrolling and certain animations.\nRaster threads: turn paint commands into actual pixels.\nWorker threads: separate JavaScript execution spaces that we create explicitly.\n\nUnfortunately, we can\u2019t control these threads however we like. The compositor and raster threads are territory the browser manages on its own, so we can\u2019t give them direct orders, and worker threads, which we can create, come with the major restriction of having no DOM access.\nSo \u201cnot using\u201d the main thread doesn\u2019t 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.\nMoving Work to the Compositor\nThe compositor thread is the reason the CSS animation didn\u2019t stop. transform and opacity don\u2019t change an element\u2019s position, size, or color in the document. They move an already-painted layer or adjust its transparency, so there\u2019s 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.\nIf 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.\ntransform: translateX \u00b7 Compositorleft \u00b7 Main thread (triggers layout)\nOnce 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 \u201cslide sideways\u201d ends up on a completely different thread depending on which property you animate. That\u2019s why it\u2019s 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.\nBut 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\u2019t 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.\n\nFirst: measure the position before the move\nLast: actually change the layout and measure the new position. Layout happens exactly once, here\nInvert: apply a transform to the element in its new position so it appears to still be in the old one\nPlay: animate that transform away. This part belongs to the compositor\n\nconst first = el.getBoundingClientRect(); // First: where it is now\n\nlist.prepend(el); // the one and only layout change\n\nconst last = el.getBoundingClientRect(); // Last: where it ended up\nconst dx = first.left - last.left;\nconst dy = first.top - last.top;\n\n// Invert: make it look like it's back at the old position \u2192 Play: release it\nel.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {\n  duration: 300,\n  easing: 'ease-in-out',\n});\nTo the user\u2019s 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\u2019s TransitionGroup and Framer Motion\u2019s layout animations are FLIP under the hood.\nThe demo below shows the difference at a glance. Press \u201cPlay rank shuffle\u201d 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 \u201cLoad the main thread\u201d and play it again. The left list stutters its way to the finish, while the right one stays smooth even under load.\nThe same rank shuffle plays in both modes at oncetop transition \u00b7 main thread1Wireless earbuds2Camping chair3Running shoes4Dehumidifier5Standing desk6Protein shakeFLIP \u00b7 compositor1Wireless earbuds2Camping chair3Running shoes4Dehumidifier5Standing desk6Protein shake\nFinally, two things worth knowing before handing work to the compositor. One is will-change: transform. It gives the browser a hint that \u201cthis element is about to change, so prepare it as its own layer in advance,\u201d which can smooth out the start of an animation. Overuse it, though, and the number of layers balloons and memory gets wasted instead.\nThe 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.\n// \ud83d\udd34 Reads and writes interleaved \u2014 forces a layout recalculation every iteration\nfor (const el of elements) {\n  const width = el.offsetWidth; // read (needs layout)\n  el.style.width = width + 10 + 'px'; // write (invalidates layout)\n}\n\n// \ud83d\udfe2 Finish all the reads, then do the writes together\nconst widths = elements.map((el) => el.offsetWidth); // gather reads\nelements.forEach((el, i) => {\n  el.style.width = widths[i] + 10 + 'px'; // gather writes\n});\nIf 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.\nSending Work to a Worker\nThen what about heavy work that can\u2019t 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.\nA 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.\n// Main thread\nconst worker = new Worker('parser.js');\nworker.postMessage(hugeRawData);\nworker.onmessage = (e) => {\n  render(e.data); // receive only the result and put it on screen\n};\nIt isn\u2019t free, of course. As we saw above, workers can\u2019t access the DOM, so they can\u2019t 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.\nSo workers aren\u2019t 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.\nSince words only go so far, let\u2019s 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.\nPress \u201cRun on main thread\u201d 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\u2019s finished. Because there are no task boundaries for paint to slip into, you couldn\u2019t show the intermediate steps even if you wanted to. Now switch to \u201cRun in worker\u201d. While the same computation runs, you get to watch the image narrow in real time.\nSeam carving \u2014 shrink the photo's width by 30% while keeping the important subjects60 fps\nThere 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.\n// Hand the pixel buffer to the worker without copying\n// After the transfer, this side can no longer use it\nworker.postMessage({ buf: pixels.buffer, width, height }, [pixels.buffer]);\nEliminating the Work Itself\nSo far we\u2019ve been asking whether a piece of work really needs to run on the main thread. This time, let\u2019s ask whether the work needs to happen at all. The best thing for performance is not doing the work in the first place.\nBackpressure 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\u2019re given. There are generally three ways to eliminate work.\nThree ways to eliminate work\nThe 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\u2019t notice. Keeping up with the present matters more than showing everything.\nThe 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.\nThe third, skipping, targets repeated work rather than incoming work. If a computation gives the same result for the same input, there\u2019s no reason to do it a second time. Remembering results and reusing them is called memoization.\nThis idea has been hiding throughout the article. Debounce skipped executions during typing, and the feed demo skipped rendering posts that weren\u2019t visible. Looked at from this angle, half of this article was about eliminating work.\nWhen 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?\nClosing\nSome 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.\nApps 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\u2019s time sparingly, and not doing work that doesn\u2019t need doing at all.\nIn 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\u2019s 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.\n\n\n\nThe compositor thread is responsible for compositing already-drawn layers onto the screen. We\u2019ll come back to it later in the article. \u21a9\n\n\nhttps://web.dev/articles/rendering-performance \u21a9\n\n\nThe HTML spec mandates a minimum 4-millisecond delay once setTimeout calls 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. \u21a9\n\n\nhttps://philipwalton.com/articles/idle-until-urgent/ \u21a9\n\n\nGranted, 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. \u21a9\n\n\nhttps://aerotwist.com/blog/flip-your-animations/ \u21a9\n\n\nhttps://en.wikipedia.org/wiki/Seam_carving \u21a9"
                ],
                "output": "readability",
                "pwd": "/data/archive/1788430023.255183",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-03T10:11:43.742270+00:00",
                "status": "succeeded"
            }
        ],
        "screenshot": [],
        "singlefile": [],
        "title": [
            {
                "cmd": [
                    "curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--max-time",
                    "120",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/0.7.4 (+https://github.com/ArchiveBox/ArchiveBox/) curl/curl 8.14.1 (x86_64-pc-linux-gnu)",
                    "https://kciter.so/posts/the-expensive-main-thread/en/"
                ],
                "cmd_version": "curl 8.14.1 (x86_64-pc-linux-gnu)",
                "end_ts": "2026-09-03T10:11:43.737451+00:00",
                "index_texts": null,
                "output": "The Browser's Main Thread Is Expensive | kciter.so",
                "pwd": "/data/archive/1788430023.255183",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-03T10:11:43.673407+00:00",
                "status": "succeeded"
            }
        ],
        "wget": [
            {
                "cmd": [
                    "wget",
                    "--no-verbose",
                    "--adjust-extension",
                    "--convert-links",
                    "--force-directories",
                    "--backup-converted",
                    "--span-hosts",
                    "--no-parent",
                    "-e",
                    "robots=off",
                    "--timeout=120",
                    "--restrict-file-names=windows",
                    "--warc-file=/data/archive/1788430023.255183/warc/1788430293",
                    "--page-requisites",
                    "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/0.7.4 (+https://github.com/ArchiveBox/ArchiveBox/) wget/GNU Wget 1.21.3",
                    "--compression=auto",
                    "https://kciter.so/posts/the-expensive-main-thread/en/"
                ],
                "cmd_version": "GNU Wget 1.21.3",
                "end_ts": "2026-09-03T10:11:43.667529+00:00",
                "index_texts": null,
                "output": "kciter.so/posts/the-expensive-main-thread/en/index.html",
                "pwd": "/data/archive/1788430023.255183",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-03T10:11:33.642074+00:00",
                "status": "succeeded"
            }
        ]
    },
    "icons": null,
    "is_archived": true,
    "is_static": false,
    "latest": {
        "archive_org": null,
        "dom": null,
        "favicon": "favicon.ico",
        "git": null,
        "media": null,
        "pdf": null,
        "screenshot": null,
        "singlefile": null,
        "title": "The Browser's Main Thread Is Expensive | kciter.so",
        "warc": null,
        "wget": "kciter.so/posts/the-expensive-main-thread/en/index.html"
    },
    "link_dir": "/data/archive/1788430023.255183",
    "newest_archive_date": "2026-09-03T10:11:47.616810+00:00",
    "num_failures": 0,
    "num_outputs": 6,
    "oldest_archive_date": "2026-09-03T10:11:33.013713+00:00",
    "path": "/posts/the-expensive-main-thread/en/",
    "schema": "Link",
    "scheme": "https",
    "snapshot_id": "9efb5ce8-cf2a-442a-a176-e192cee5fb35",
    "sources": [
        "/data/sources/1788430023-import.txt"
    ],
    "tags": null,
    "tags_str": "",
    "timestamp": "1788430023.255183",
    "title": "The Browser's Main Thread Is Expensive | kciter.so",
    "updated": "2026-09-03T10:11:33.013198+00:00",
    "updated_date": "2026-09-03 10:11",
    "url": "https://kciter.so/posts/the-expensive-main-thread/en/"
}