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.
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.
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).
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
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:
Choose a task queue that has a runnable task (implementation-defined which).
Take the first task from it and run it. Nothing else on this thread runs meanwhile.
Perform a microtask checkpoint: drain the microtask queue completely.
Report the task as a long task if it ran long (the standard hooks into the Long Tasks API here).
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.
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.
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.
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)
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:
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.
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.
constSLICE_BUDGET_MS = 8; // illustrative: keep each slice well under 50 mslet latestSearch = 0;
asyncfunctionsearch(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) {
awaityieldToMain(); // input and rendering can run nowif (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.
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
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.functionspin() {
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;
functionspin() {
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) {
awaitPromise.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
How to read this: the three warning boxes are one task. While it runs, the next keystroke can only wait.
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
Reproduce in the lab. Throttle the CPU in the browser's performance tools, record typing, and read the main-thread timeline before changing anything.
Ship behind a flag to a small share of sessions and compare field INP for flagged and unflagged traffic.
Check correctness: type quickly, delete and paste, and confirm the list always matches the final text (the stale guard is the likeliest bug).
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).
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.
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.