Skip to content

    Frontend System Design

    The Event Loop and the Main Thread

    Learn how the browser event loop schedules tasks, microtasks and rendering, so you can find long tasks, yield, chunk work and move it to a Web Worker.

    By Published 21 min read

    • frontend-system-design
    • event-loop
    • main-thread
    • long-tasks
    • web-workers
    Share

    This lesson is part of Frontend System Design Foundations: How the Web Runs. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to explain what runs on a page's main thread, describe the event loop as the HTML Standard defines it (task queues, microtasks, the "update the rendering" step), predict the output of code that mixes setTimeout, promises, await and requestAnimationFrame, spot long tasks and break them up by yielding or chunking, decide when a Web Worker is the right tool, and connect all of this to the Interaction to Next Paint metric.

    Picture a restaurant with one waiter. Customers raise a hand (clicks, key presses), the kitchen rings a bell (network responses), and a timer on the wall reminds the waiter to refill water. The waiter can do only one thing at a time, and cannot be interrupted halfway through. If one customer asks for a task that takes ten minutes, everyone else waits, however politely they raise their hands.

    A browser tab works the same way. Your JavaScript, the page's styling and layout, and the handlers for every click and key press share one thread of execution, the main thread. The rule that decides what the thread does next is the event loop. Almost every "the page feels stuck" bug is a story about someone holding the waiter for too long.

    The running example for this lesson is a product search box on a catalogue page. As the user types, the page filters a large list of products (50,000 names, an illustrative size) and shows the matches. The first version is four lines long and feels fine on a developer laptop. On a mid-range phone, typing stutters. The rest of the lesson explains why and what to do about it.

    The core idea

    Hold on to three statements. Everything else in this lesson follows from them.

    1. JavaScript on a page runs to completion on one thread. While your function runs, nothing else on that thread runs: no other handler, no style or layout work, no painting a new frame.
    2. The event loop picks the next piece of work. Pieces of work are tasks. After each task, the loop runs all pending microtasks (promise callbacks, for instance), and at certain moments it also updates the rendering (runs style, layout and paint preparation for the page).
    3. Responsiveness is the time a piece of work waits plus the time it takes. A key press that arrives while a long task is running waits for that task to finish. You can make the wait shorter only by making tasks shorter, by splitting them, or by running the work somewhere else.

    This lesson describes behaviour from the HTML Living Standard (the specification browsers implement) unless it says otherwise, and says where support varies.

    What runs on the main thread

    Sharing the main thread's one queue of turns are:

    • Script: your JavaScript, including event handlers, timer and promise callbacks, and every library you ship.
    • Style calculation and layout: working out which CSS rules apply to which elements, then the size and position of every box.
    • Paint preparation: recording what to draw. Browsers do some later rendering work on other threads (the compositor is one example, and designs differ by engine), but the steps above, and the script that triggers them, wait their turn here. How the engine turns this into pixels is the subject of the lessons How a Browser Actually Works and The Critical Rendering Path.
    • Input handling: the page's handlers for click, input, keydown and so on.

    Because style and layout share a thread with your script, a long script delays the next frame, not only the next click.

    Figure 1. The main thread and the event loop

    Sources of work feed task queues; the event loop runs one task at a time on the main threadUser input to Task queues; Timers to Task queues; Network to Task queues; Task queues to Event loop (next task); Event loop to Page work (run); Page work to Microtask queue (queues jobs); Microtask queue to Event loop (drain)MAIN THREADUser input: clicks, keys, scrollUser inputclicks, keys,scrollTimers: setTimeoutTimerssetTimeoutNetwork: fetch, messagesNetworkfetch, messagesTask queues: grouped by task sourceTask queuesgrouped by tasksourceEvent loop: one task at a timeEvent loopone task at atimePage work: script, layoutPage workscript, layoutMicrotask queue: promise jobsMicrotaskqueuepromise jobsnext taskrunqueues jobsdrain

    How to read this: things that happen (input, timers, network responses) add tasks to queues; the event loop takes one task, runs it on the main thread, then drains the microtask queue before it takes another.

    The event loop, as the specification defines it

    The HTML Standard describes the event loop as an algorithm. Reading it directly removes most of the folklore ("macrotask", "the browser renders every 16 ms") that circulates. The parts that matter for a front-end designer are these.

    Task queues and task sources. A task is a unit of work the browser queues for the loop: a click being dispatched, a timer firing, a network response being handed to a callback, a message arriving. Each task comes from a task source (the standard defines generic ones such as DOM manipulation, user interaction, networking, navigation and traversal, and rendering; timers and port messages have their own). Every task source maps to one task queue, and a task queue is a set of tasks. An event loop has one or more queues, and the browser chooses which queue to serve next in an implementation-defined way. The standard's own example is that a browser may favour input events over other work. What it guarantees is narrower: tasks from the same task source run in the order they were queued.

    The microtask queue is separate. It is not one of the task queues. Promise reaction callbacks, queueMicrotask callbacks and MutationObserver callbacks go here. After every task, and after any callback when the JavaScript stack becomes empty, the browser performs a microtask checkpoint: it runs microtasks until the queue is empty, including microtasks added while it drains.

    One iteration, in plain steps:

    1. Choose a task queue that has a runnable task (implementation-defined which).
    2. Take the first task from it and run it. Nothing else on this thread runs meanwhile.
    3. Perform a microtask checkpoint: drain the microtask queue completely.
    4. Report the task as a long task if it ran long (the standard hooks into the Long Tasks API here).
    5. If it is time to render, the browser does so as its own task, described next.

    Figure 2. One iteration of the loop

    flowchart TD accTitle: One iteration of the event loop accDescr: The loop picks a task queue, runs one task, drains all microtasks, and then repeats, with rendering handled as a task of its own when the browser has a rendering opportunity. A["Pick a queue with a runnable task"] --> B["Run the first task in it"] B --> C["Microtask checkpoint:
    drain every microtask"] C --> D{"Rendering
    opportunity?"} D -->|"no"| A D -->|"yes"| E["Update the rendering
    (its own task)"] E --> C2["Microtask checkpoint"] C2 --> A

    How to read this: the loop only ever goes around once per task. Microtasks always finish before the next task starts, and rendering is something the loop does between tasks, never in the middle of one.

    Update the rendering and rendering opportunities

    Rendering is not tied to every loop turn. A rendering opportunity exists when the browser is able to present the page, taking into account display refresh rate and throttling (a hidden tab may get far fewer). The standard mandates no rate: 60 Hz is common, and the browser may skip an opportunity, for example when nothing would change on screen.

    When one arrives, the browser queues a task on the rendering task source to run the "update the rendering" algorithm. Of its many steps, the figure shows the ones a front-end developer feels, in the standard's order. Resize and scroll events fire first, then media queries are evaluated and CSS animations advance, then the animation frame callbacks (what you passed to requestAnimationFrame) run, then style and layout are recalculated and ResizeObserver notifications delivered, then IntersectionObserver steps run and the page is painted.

    Figure 3. What "update the rendering" does, in order

    flowchart LR accTitle: Order of steps in update the rendering accDescr: Resize and scroll steps, media queries and animations, requestAnimationFrame callbacks, style and layout with ResizeObserver, IntersectionObserver, then paint. S1["Resize and
    scroll steps"] --> S2["Media queries,
    animations"] S2 --> S3["requestAnimation-
    Frame callbacks"] S3 --> S4["Style and layout,
    ResizeObserver"] S4 --> S5["IntersectionObserver
    steps"] S5 --> S6["Paint"]

    How to read this: requestAnimationFrame callbacks run before style and layout in the same frame, which is why a style change made inside one is drawn in that frame rather than the next. If your handlers or callbacks make this whole sequence late, the frame is late.

    This matches MDN's description that requestAnimationFrame calls your function "before the next repaint". The callback fires once, so to animate you request the next frame from inside it, and MDN notes that calls are generally paused in background tabs.

    Order of execution: four examples

    Examples 1 to 3 use only features that behave the same in Node.js, so their output was confirmed by running them there (Node v26.12). Example 4 uses requestAnimationFrame, which Node does not have, so its order is reasoned from the HTML Standard steps above, not from a run.

    Example 1: sync code, microtasks, then a timer

    console.log("A");
    setTimeout(() => console.log("setTimeout"), 0);
    Promise.resolve().then(() => console.log("promise.then"));
    queueMicrotask(() => console.log("queueMicrotask"));
    console.log("B");

    Output:

    A
    B
    promise.then
    queueMicrotask
    setTimeout

    The script itself is one task. It prints A and B as it runs. It queues a timer task, and two microtasks in the order they were written. When the script task ends, the microtask checkpoint runs both microtasks. The timer task runs on a later turn. Note the "0": it is a request for no minimum delay, not a promise to run before other queued work.

    Example 2: await is a microtask

    async function load() {
      console.log("load: start");
      await null;
      console.log("load: after await");
    }
    console.log("1");
    load();
    Promise.resolve().then(() => console.log("then"));
    console.log("2");

    Output:

    1
    load: start
    2
    load: after await
    then

    An async function runs synchronously until its first await, which is why load: start prints before 2. The rest of the function becomes a promise reaction job (a microtask), queued at the moment of the await. That is before the .then callback was queued, so it runs first. The rule that await of a value or a native promise takes one microtask turn comes from the ECMAScript language specification, not the HTML Standard.

    Example 3: a microtask runs before the next timer

    setTimeout(() => {
      console.log("timer 1");
      Promise.resolve().then(() => console.log("timer 1 microtask"));
    }, 0);
    setTimeout(() => console.log("timer 2"), 0);

    Output:

    timer 1
    timer 1 microtask
    timer 2

    Each timer callback is its own task. The microtask queued inside the first one is drained at that task's checkpoint, so it runs before the second timer task. The standard does not promise that timers run exactly on schedule, only the ordering rules above.

    Example 4: animation frame callbacks (browser only)

    requestAnimationFrame(() => {
      console.log("raf 1");
      Promise.resolve().then(() => console.log("raf 1 microtask"));
    });
    requestAnimationFrame(() => console.log("raf 2"));

    Printed output, reasoned from the specification: raf 1, raf 1 microtask, raf 2. Both callbacks sit in the same list and run in the same "update the rendering" pass. After the first callback returns and the JavaScript stack is empty, a microtask checkpoint runs, so the microtask finishes before the second callback starts.

    What this example deliberately does not claim: the order of a setTimeout(fn, 0) relative to a requestAnimationFrame callback. The first depends on when the timer task is picked, the second on when the browser gets a rendering opportunity, and the specification fixes neither. Code that needs one to precede the other should not rely on timing.

    Why long tasks make input feel slow

    A long task is any task that keeps the main thread busy for more than 50 ms (web.dev's definition; MDN words it as the main UI thread being busy "for 50ms or longer"). The portion above 50 ms is the task's blocking period (web.dev's term; the Total Blocking Time metric adds these up). You can observe long tasks in the browser with a PerformanceObserver:

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        console.log(`long task: ${Math.round(entry.duration)} ms`);
      }
    });
    observer.observe({ type: "longtask", buffered: true });

    The Long Tasks API is not a standard and MDN marks it as limited availability and experimental, so treat it as a lab and diagnostic tool and check MDN's compatibility table before depending on it in production.

    Why does a long task hurt input? A key press is queued as a task when it arrives. It cannot start until the current task and its microtasks finish. The user sees nothing happen. When the long task ends, the handler runs, and only after it does the browser get to render the result.

    Figure 4. A key press arrives during a long task

    sequenceDiagram accTitle: A keystroke waiting behind a long task accDescr: The user types a letter, the main thread runs a long filtering task, a second keystroke arrives and waits, and only after the task ends does the next handler run and the screen update. participant U as User participant Q as Event queue participant M as Main thread U->>Q: types "s" Q->>M: run input handler Note over M: filters 50,000 items
    (long task, illustrative) U->>Q: types "h" Note over Q: waits, nothing can run M-->>Q: task ends, microtasks drain M->>M: update the rendering Q->>M: run handler for "h"

    How to read this: the second keystroke is not lost, it is late. The delay it experiences is the unfinished part of the first task, which is why splitting tasks directly lowers latency.

    The naive version of the running example does exactly this:

    const input = document.querySelector("#search");
    
    input.addEventListener("input", () => {
      const query = input.value.toLowerCase();
      const matches = products.filter((p) => p.name.toLowerCase().includes(query));
      renderList(matches); // builds DOM for every match
    });

    Both the scan and the DOM building run inside one task.

    Yielding to the event loop

    To yield is to end your task and arrange for the rest of the work to run as a later task. Between the two, the loop is free to run queued input and to render. The tools differ in where the continuation lands in the queue.

    • setTimeout(fn, 0) queues a timer task. It works everywhere, and the continuation goes behind work already queued. A subtle cost: per the HTML Standard's timer steps, once timers are nested more than five levels deep (a timer callback that sets another timer), a delay below 4 ms is raised to 4 ms. A loop that yields with setTimeout on every chunk pays that minimum after the first few chunks.
    • MessageChannel: post a message to yourself and continue in the message handler. Each message is queued as a task and is not subject to the timer clamp. MDN lists it as widely available.
    • scheduler.postTask(callback, options) schedules a task with a priority ("user-blocking", "user-visible" by default, or "background"), an optional delay and an abort signal, and returns a promise.
    • scheduler.yield() returns a promise that resolves in a later task, so you can write await scheduler.yield() in a loop. Its continuation goes in a boosted queue and runs ahead of other tasks of the same priority queued after you yielded, avoiding the "back of the line" cost of setTimeout.

    Support is partial. At the time of writing, MDN's compatibility data lists scheduler.postTask in Chrome and Edge 94 and Firefox 142, and scheduler.yield in Chrome and Edge 129 and Firefox 142 (web.dev agrees for yield), with Safari not supporting either. MDN marks both as not Baseline. Always feature-detect, and check MDN's compatibility table before you decide.

    A small helper keeps the fallback in one place:

    function yieldToMain() {
      if (globalThis.scheduler?.yield) {
        return scheduler.yield();
      }
      return new Promise((resolve) => setTimeout(resolve, 0));
    }

    Browsers without scheduler.yield still yield through the fallback, but without its prioritised continuation.

    Chunking work

    Chunking means splitting one long job into slices, each short enough to be a small task, and yielding between slices. Here is the search box rewritten. Each call to search carries an id, so a newer keystroke makes the older, unfinished search quit.

    const SLICE_BUDGET_MS = 8; // illustrative: keep each slice well under 50 ms
    let latestSearch = 0;
    
    async function search(query) {
      const id = ++latestSearch;
      const q = query.toLowerCase();
      const matches = [];
      let sliceStart = performance.now();
    
      for (const product of products) {
        if (product.name.toLowerCase().includes(q)) matches.push(product);
    
        if (performance.now() - sliceStart > SLICE_BUDGET_MS) {
          await yieldToMain();                 // input and rendering can run now
          if (id !== latestSearch) return;     // a newer search started: stop
          sliceStart = performance.now();
        }
      }
      renderList(matches.slice(0, 50));        // build DOM for the first 50 only
    }
    
    input.addEventListener("input", () => search(input.value));

    Three ideas are in there. The scan is cut into slices by time, not item count, so a slow phone yields more often. A stale-result guard stops wasted work. And the DOM work is bounded by showing only the first 50 matches (list virtualisation goes further, but is outside this lesson).

    Figure 5. Chunked search with a stale-search guard

    sequenceDiagram accTitle: Chunked search yielding to input accDescr: The search scans one slice, yields, a new keystroke starts a newer search, the old search resumes, sees it is stale and stops, and the newer search renders its results. participant U as User participant M as Main thread U->>M: types "s" M->>M: search 1: scan slice M->>M: yield to the event loop U->>M: types "h" (handler runs now) Note over M: latestSearch becomes 2 M->>M: search 1 resumes, sees it is stale, returns M->>M: search 2: scans slices, yielding between them M->>M: search 2 renders the first 50 matches

    How to read this: each yield is a gap where the keystroke handler and rendering can run. Without the gaps, "h" would wait for the whole scan of "s".

    Debouncing (waiting until the user pauses typing) reduces how many searches start, but not how long each one blocks, and it adds a delay. Chunking shortens each block. They combine well.

    Moving work off the main thread: Web Workers

    If the work is heavy enough that even short slices add up to a laggy page, move it off the main thread. A Web Worker is a script that runs in its own thread with its own event loop, and talks to the page by passing messages.

    // search-worker.js
    let products = [];
    
    onmessage = (event) => {
      const message = event.data;
      if (message.type === "load") {
        products = message.products;
        return;
      }
      const q = message.query.toLowerCase();
      const matches = products
        .filter((p) => p.name.toLowerCase().includes(q))
        .slice(0, 50);
      postMessage({ id: message.id, matches });
    };
    // main.js
    const worker = new Worker("search-worker.js");
    worker.postMessage({ type: "load", products });
    
    let latestSearch = 0;
    input.addEventListener("input", () => {
      worker.postMessage({ id: ++latestSearch, query: input.value });
    });
    worker.onmessage = (event) => {
      if (event.data.id === latestSearch) renderList(event.data.matches);
    };

    What a worker can and cannot touch (from MDN's guide to using Web Workers):

    • Cannot: read or change the DOM, use the page's objects directly, or use window (the worker's global is self).
    • Can: run any JavaScript, including heavy computation; use fetch(), XMLHttpRequest, WebSockets and IndexedDB; use setTimeout, setInterval and importScripts(); start same-origin sub-workers; and send and receive messages.

    Figure 6. The main thread and a worker

    The page's main thread passes messages to a worker that does the heavy computationSearch box and list to Page code (input events); Page code to Search worker (postMessage); Search worker to Page code (results)PAGE MAIN THREADWORKER THREADSearch box and list: DOM, eventsSearch box andlistDOM, eventsPage code: handlers, DOMPage codehandlers, DOMSearch worker: filter, no DOMSearch workerfilter, no DOMinput eventspostMessageresults

    How to read this: only the main thread touches the DOM. The worker receives a message, computes, and sends a message back, and that reply is itself a task on the main thread.

    Workers have real costs, so they are not a free upgrade:

    • Data is copied. postMessage uses the structured clone algorithm by default, serialising the data on one side and rebuilding it on the other. Large arrays pay for that copy. Transferable objects such as ArrayBuffer can be moved without a copy, after which the sender can no longer use them. SharedArrayBuffer lets threads share memory, but MDN flags determinism, security and performance concerns and extra requirements, so read its page before relying on it.
    • The result still lands on the main thread. A worker that returns 50,000 rows to render has only moved the cost, not removed it.
    • There is startup, bundling and debugging overhead, and state now lives on both sides.
    • Cancelling is cooperative. A worker already running a stale query will finish it before it reads the next message, so keep jobs short or check a flag between slices.

    Microtask starvation

    Microtasks look like a free way to run something "right after" the current code. The danger is in the rule from the specification: the checkpoint drains the queue including microtasks added during the drain. If microtasks keep adding microtasks, the queue never empties, the loop never reaches the next task, and rendering and input never run.

    // WARNING: this freezes the tab it runs in.
    function spin() {
      queueMicrotask(spin);
    }
    spin();
    setTimeout(() => console.log("never printed"), 0);

    Here the timer is queued, but the loop is stuck in the microtask checkpoint of the first task, so the timer's task never starts. Compare a version that recurses through setTimeout: each step is a separate task, so input and rendering can interleave (subject to the timer clamp above), and the page stays usable.

    A bounded version shows the order safely. Output confirmed in Node:

    let n = 0;
    function spin() {
      if (++n < 5) queueMicrotask(spin);
      console.log("microtask", n);
    }
    setTimeout(() => console.log("timeout"), 0);
    spin();
    // microtask 1, 2, 3, 4, 5 and only then: timeout

    The same trap hides in async code, because await does not yield to the event loop. It only yields to the microtask queue. This loop starves the page just as queueMicrotask(spin) does, if its condition never becomes true:

    // WARNING: never ends if `ready` is only set by a click or a timer.
    while (!ready) {
      await Promise.resolve();
    }

    (A bounded await null loop in Node printed all its iterations before a pending setTimeout(…, 0), which confirms the point.) To wait for something that happens in another task, wait on a promise that the event resolves, or use a task-based yield.

    Connection to Interaction to Next Paint

    Interaction to Next Paint (INP) is the Core Web Vital that measures how responsive a page is to clicks, taps and key presses across a visit. web.dev divides one interaction into three phases:

    • input delay: the time before the handlers start, often caused by a busy main thread (a long task in progress);
    • processing duration: the time the handlers take to run;
    • presentation delay: the time from the handlers finishing to the next frame being painted.

    Long tasks and starved microtasks inflate input delay, slow handlers inflate processing duration, and heavy style or layout work after the handler inflates presentation delay. Chunking and workers attack the first two. web.dev's thresholds, at the 75th percentile of page loads, are 200 ms or less for "good" and above 500 ms for "poor". How to collect the metric in the field is covered in the lesson Loading Performance and Core Web Vitals; for measuring in production in general, see observability and SLOs.

    Choosing how to defer work

    Figure 7. Which tool for which job

    flowchart TD accTitle: Choosing a way to defer or move work accDescr: A decision tree. If the work must finish before the next task, use a microtask. If it is heavy computation not needing the DOM, use a Web Worker. If it must change the next frame, use requestAnimationFrame. If it can be sliced, chunk it and yield. Q1{"Must it finish before
    anything else runs?"} -->|"yes, and it is tiny"| M["Microtask
    (queueMicrotask, then)"] Q1 -->|"no"| Q2{"What kind
    of work?"} Q2 -->|"small visual update
    for the next frame"| R["requestAnimationFrame"] Q2 -->|"heavy, needs
    no DOM"| W["Web Worker"] Q2 -->|"long job that
    needs the DOM"| Q3{"Can it be split
    into slices?"} Q3 -->|"yes"| Y["Chunk and yield
    (scheduler.yield or fallback)"] Q3 -->|"no"| X["Reduce the work
    or change the design"]

    How to read this: start at the top and answer honestly. The most common outcome for UI work is "chunk and yield", and the most common mistake is reaching for a microtask when the real need is to let the browser breathe.

    Method What it does When it runs Good for Watch out for
    queueMicrotask, Promise.then, await Queues a microtask At the end of the current task or callback, before anything else Making sync-looking code consistently async; batching within one task Does not yield: starves rendering and input if it keeps re-queuing
    setTimeout(fn, 0) Queues a timer task A later task, behind work already queued A universal yield; simple fallback Not exact timing; 4 ms minimum once nested more than five levels
    MessageChannel Posts a message to yourself, handled as a task A later task, not subject to the timer clamp A yield without clamping when scheduler is missing Plumbing code; a workaround rather than a scheduling API
    requestAnimationFrame Runs a callback in the next "update the rendering" Before the next style and layout, once per rendering opportunity Visual updates that must land in the next frame Not for heavy work (it delays the frame); paused in background tabs
    scheduler.postTask Queues a prioritised task, with delay and abort A task chosen by priority Splitting work by importance Partial support (not in Safari at the time of writing)
    scheduler.yield Yields, then continues in a boosted queue A later task, ahead of same-priority newly queued tasks Chunk loops in async code Partial support; feature-detect and fall back
    Web Worker Runs script on another thread Concurrently, on the worker's own event loop Heavy computation without the DOM Copying data, no DOM access, results still handled on the main thread

    In practice: a search box that stutters on mid-range phones

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    A retail catalogue page loads about 50,000 product names (illustrative) into memory for instant search. The search box filters the whole array on every input event and rebuilds the visible list, as in the naive code above. On the team's laptops it feels instant. Support tickets from customers on lower-powered phones say "typing lags and letters appear in bursts". The field data shows poor INP on the search page and a dominant input-delay phase, which matches key presses waiting behind filtering tasks. Four engineers own the page.

    Starting architecture

    Figure 8. Everything on the main thread

    Starting architecture: key presses, filtering and DOM building all share the main threadShopper typing to Input handler (keystrokes); Input handler to Filter 50,000 names; Filter 50,000 names to Build list DOMMAIN THREADShopper typingShopper typingInput handler: runs on every keyInput handlerruns on every keyFilter 50,000 names: one long taskFilter 50,000namesone long taskBuild list DOM: every matchBuild list DOMevery matchkeystrokes

    How to read this: the three warning boxes are one task. While it runs, the next keystroke can only wait.

    Options considered

    Option What it fixes What it costs Risk
    Debounce the input Fewer searches start A delay before any result Each search still blocks for its full length
    Chunk, yield, cap rendered rows One long task becomes many short ones Async code, a stale-search guard Slightly more total time
    Web Worker Scan leaves the main thread Worker file, messages, a copy of the data Stale queries finish before the next starts
    Server-side search (see search indexing) Client does no filtering A round trip per query, an API to run Latency and availability now matter

    The decision

    Field Decision record
    Context A full-array filter runs as one long task per keystroke on slow phones, hurting input delay and INP.
    Decision Chunk the scan with a time budget, guard against stale searches, render only the first 50 matches. Keep the feature-detected yieldToMain() helper in one module.
    Consequences, good No code path holds the main thread for long; typing stays responsive; works without worker infrastructure.
    Consequences, bad Results arrive in slices over a few frames; async control flow is harder to test.
    Revisit when Field INP for the page is still above the "good" threshold after rollout, or per-slice time is dominated by work that cannot be split. Then move the scan to a worker.

    Rollout and verification

    1. Reproduce in the lab. Throttle the CPU in the browser's performance tools, record typing, and read the main-thread timeline before changing anything.
    2. Ship behind a flag to a small share of sessions and compare field INP for flagged and unflagged traffic.
    3. Check correctness: type quickly, delete and paste, and confirm the list always matches the final text (the stale guard is the likeliest bug).
    4. Test the fallback path in a browser without scheduler.yield.

    The 2 a.m. failure

    Two weeks later, support reports that on one popular phone the search page "hangs completely" after the user opens the filter panel and returns. Search sessions drop, and the field INP numbers for that page regress.

    The cause is not the new search code. A helper in the filter panel waits for the catalogue with while (!catalogueReady) { await Promise.resolve(); }. Usually the flag is already set. When the user returns to the page, it is set only from a message handler, which is a later task. The loop keeps queueing microtasks, the event loop never reaches that task, and the tab freezes: exactly the starvation shown earlier.

    First three checks: (1) profile a throttled device and look for one task that never ends; (2) search the code for loops containing await on an already-settled value, or recursive queueMicrotask; (3) find what sets the awaited flag and which task it runs in. Mitigation: turn the filter panel off behind its flag. Follow-up: replace the polling loop with a promise that the readiness event resolves, add a review checklist item against awaiting inside unbounded loops, and test the "return to page" path with the message delayed.

    Architect's notes

    • Set a main-thread budget early. Agree on a rule such as "no task over 50 ms in common flows" and make it part of review, because retrofitting is expensive.
    • Measure before you move work. A profile tells you whether the cost is script, layout or rendering. A worker will not fix a layout problem.
    • Keep heavy logic in pure functions that take data and return data, so moving them to a worker later is cheap.
    • Defer workers until a measurement demands them. Chunking and capping rendered rows give the largest win for the lowest complexity.
    • Keep the yield helper in one module. Support for the scheduler APIs is moving; one place with feature detection is easy to update.

    Common mistakes

    • Thinking await yields to the browser. It only yields to microtasks. Rendering and input stay blocked.
    • Using microtasks to "run it later". They run before the next task, so the browser cannot paint or handle input in between.
    • Assuming setTimeout(fn, 0) runs "immediately" or in a fixed order relative to rendering. It is a request for the earliest task, nothing more.
    • Doing heavy work inside requestAnimationFrame. It runs in the frame's critical path, so a slow callback delays the frame.
    • Chunking without a stale guard. Old slices keep running after the user has typed something new.
    • Moving work to a worker and then rendering a huge result. The cost reappears on the main thread.
    • Treating scheduler.yield or postTask as available everywhere. Feature-detect and keep a fallback.

    Key takeaways

    • The page's script, style, layout and input handlers share one main thread, and a running task cannot be interrupted.
    • The event loop takes one task, runs it, drains all microtasks, and updates the rendering when the browser has a rendering opportunity.
    • The standard does not say how often rendering happens: it depends on refresh rate and throttling, and requestAnimationFrame callbacks run inside the "update the rendering" step before style and layout.
    • Typical ordering: synchronous code first, then microtasks (then, queueMicrotask, code after await), then later tasks such as timers.
    • A long task is a task of more than 50 ms of main-thread work. It makes input wait, which shows up in INP as input delay.
    • Yield to let queued input and rendering run: scheduler.yield() where supported, with a setTimeout or MessageChannel fallback.
    • Chunk long jobs by time budget, guard against stale work, and bound the DOM you build.
    • Web Workers run heavy, DOM-free work on another thread, but data is copied, the DOM is off limits, and results still return to the main thread.
    • Microtasks that re-queue microtasks starve the page: await is not a yield to the event loop.

    Practice exercises

    Exercise 1 — Starter (30 min): Predict, then run in Node, the output of a snippet that mixes setTimeout, Promise.then, queueMicrotask and an async function with two awaits. Write down where each line is queued before you run it, and note any line you got wrong.

    Exercise 2 — Intermediate (2 hours): Build a page with a text box and 50,000 generated strings. Implement the naive filter, record a performance profile with CPU throttling, and note the longest task. Then add time-sliced filtering with yieldToMain() and a stale guard and compare. Test the fallback by temporarily making scheduler.yield undefined.

    Exercise 3 — Advanced (half day): Move the same filter into a Web Worker using the message protocol from this lesson. Measure long tasks again and record the new costs that appeared (data copy at load, stale queries finishing late).

    In this course:

    Elsewhere on the site:

    Official documentation

    Enroll to keep reading

    Lesson 5 of 8 · Frontend System Design Foundations: How the Web Runs

    Learn how the browser event loop schedules tasks, microtasks and rendering, so you can find long tasks, yield, chunk work and move it to a Web Worker.

    In the rest of this lesson

    • Why long tasks make input feel slow
    • Yielding to the event loop
    • Chunking work
    • Moving work off the main thread: Web Workers
    • Microtask starvation
    • Connection to Interaction to Next Paint

    Enrolling is free. It unlocks every lesson in Frontend System Design Foundations: How the Web Runs, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments