Main Thread Starvation
A page blocks the renderer’s main JavaScript thread with synchronous computation, synchronous IPC, or large DOM work, holding it for longer than the RAIL Response budget; input events queue, frames drop, and the user perceives the page as locked up.
Main Thread Starvation is the browser form of a familiar failure: one task holds the only thread that can answer the user. In Chromium, that thread runs page JavaScript, input dispatch, and the main-thread stages of the Rendering Pipeline: Parse, Style, Layout, and Paint. Once a task holds it past the RAIL Performance Model’s 50 ms Response budget, input events wait behind it. The compositor may ask for another frame, but the main thread cannot supply the work it owns. The user sees a click that does not land, a scroll that stutters, or a text field that drops keystrokes.
Symptoms
- DevTools’ Performance panel marks tasks longer than 50 ms with a yellow Long Task label. Frames longer than 16 ms show red on the frame ribbon.
- Interaction to Next Paint (INP), surfaced by the Web Vitals JavaScript library and aggregated in the Chrome User Experience Report, sits above the 200 ms good threshold and often crosses the 500 ms poor threshold.
- A
PerformanceObserverregistered forlongtaskentries fires during normal interaction. Each entry carries adurationand anattributionfield naming the script or iframe that produced it. - Users describe symptoms rather than stacks: “clicks don’t register,” “scroll stutters under my finger,” “the page locks up when I start typing,” or “the spinner runs forever even when the data has already loaded.”
- The Perf Sheriff dashboard at
chromeperf.appspot.comopens a regression alert against the team’s last landed change and attaches a captured profile. - A
chrome://tracingcapture shows the scheduler holding the page inRAILMode::kResponseafter input while one renderer-main-thread task runs for hundreds of milliseconds. - Gerrit review carries the familiar comment: “this needs to move off-thread.” An OWNER has recognized the long-task shape before the benchmark does.
- An AI coding agent receives “performance: keep work fast” without a number, then emits an input handler that runs a 300 ms synchronous parse.
Why It Happens
The main thread is the default. addEventListener('input', fn) runs fn there. array.map(transform) runs transform there. XHR and fetch().then() callbacks resume there. Web Workers, Worklets, scheduler.postTask() with a lower priority, and requestIdleCallback all require a deliberate choice. The default path is shorter: write the function, attach the handler, ship the code.
The Response budget is also widely misquoted. Casual blog posts and interviewer scripts often repeat 200 ms or 100 ms as if those were the page’s budget. The correct page-side figure is 50 ms. The 100 ms number is the full perception window, including browser input handling and frame production. A team that permits 200 ms long tasks has already under-protected interactivity before any specific function runs.
The synchronous version often looks safest in code review. A function that parses 5 MB of JSON in place has no message boundary, lifecycle problem, or concurrency bug. Its cost is invisible in the diff and visible only when the browser runs it against real input. Reviewers can prove correctness in their heads; they cannot see the 350 ms task until instrumentation reports it.
Synchronous IPC is the hidden form. A renderer-to-browser-process call made synchronously from the main thread blocks until the browser process responds. Modern Chromium IPC, Mojo over ipcz, generally exposes asynchronous interfaces and marks synchronous wrappers, but legacy WebAPI surfaces still exist: localStorage, sessionStorage, synchronous XMLHttpRequest, and older Web Bluetooth or Web USB call paths. A 30 ms localStorage.getItem inside an input handler looks like ordinary state access. On a loaded device it is already a budget breach.
The antipattern can also arrive by accumulation. Three calls of 18 ms each look acceptable alone. In one task they total 54 ms, and the budget is gone. Profile-driven review often stops at the first highlighted function, even when the real fault is the whole task.
The fix is architectural, which is why the problem returns. Moving work off-thread requires serializable data, a Worker boundary, message handling, and cross-boundary error handling. Under feature pressure, the synchronous version wins until a Perf Sheriff regression forces the work back onto the queue.
The Harm
The symptom is simple: the page feels frozen. A 50 ms response feels instant. A 100 ms response feels acknowledged. A 200 ms response feels delayed. A 500 ms response sends the user toward the back button. A button that takes 300 ms to answer a click is broken in the user’s experience even when the handler eventually runs.
INP makes the harm visible. The metric, one of the Core Web Vitals graded against Chrome’s CrUX dataset, measures the worst interaction-to-paint latency over a visit. A page that produces a single 400 ms long task in a normal session can land in the poor bucket. CrUX reports the field symptom, not an ideal synthetic profile.
Downstream Chromium-based products absorb the complaint. In Electron applications, WebView2 integrations, and in-browser AI editors, a starved renderer can make the whole product feel frozen. The Electron main process and the renderer main thread are not the same thread, but the user does not see that boundary.
At project scale, the antipattern becomes Perf Sheriff work. A landed change that introduces a 100 ms long task on a hot path raises a benchmark alert. The on-call Perf Sheriff bisects it, files a bug with an SLA, and waits for the originating team. Downstream teams without comparable instrumentation pay the same cost later, with less evidence.
Battery and thermal cost compound the latency. A long main-thread task is a sustained high-frequency CPU burst. On mobile and constrained desktop hardware, repeated bursts trigger thermal throttling and shorten battery life.
The noise also hides the next regression. A page that always produces long tasks has a noisy INP histogram and a noisy long-task distribution. Tests watching those distributions become less sensitive.
For an AI coding agent, the failure mode is plausible code with broken user behavior. The function passes lint and unit tests. The defect appears only when a user runs it against real input.
The Way Out
The correct figure is 50 ms. The moves that restore it are off-thread execution, task chunking, and avoiding synchronous IPC.
Move long computation into a Web Worker. The same 350 ms operation may take a similar wall-clock time there, but the main thread remains free to handle input and produce frames. Worklets (PaintWorklet, AudioWorklet, AnimationWorklet) apply the same principle inside specific browser subsystems. The cost is serialization, message passing, and cross-boundary error handling.
Split work across tasks. scheduler.postTask() accepts user-blocking, user-visible, and background priorities, then yields to the browser between scheduled tasks. A loop that once ran as one 200 ms task can process 25 ms chunks so input and frame production interleave. The web.dev Optimize long tasks guide documents the pattern; legacy code may still use setTimeout(fn, 0) when priority controls are unnecessary.
Avoid synchronous IPC on the main thread. Mojo interfaces over ipcz should be asynchronous; the [Sync] Mojo annotation is a hard cost on every call and belongs only where the API contract cannot be expressed asynchronously. Legacy synchronous WebAPIs should be wrapped in asynchronous equivalents or invoked from a Worker.
Instrument before the regression ships. Register a PerformanceObserver for longtask and event entries. Surface counts and duration in analytics. Alert when population INP crosses the good threshold. Pair those measurements with the RAIL Performance Model’s budgets so the team compares against a number, not an intuition.
In review, ask whether the function fits the budget. A function that runs on the main thread and exceeds a declared cost needs a profile before approval. Correct code can still be unaffordable code.
For downstream Chromium-based products, ship a release mechanism that can kill a regression server-side via Feature Flag Guarding and Finch Variations. The mechanism turns a multi-week exposure into an hours-long one.
How It Plays Out
A side-panel summary view runs a small JavaScript model on the user’s first interaction. The first open freezes the page for 350 ms while the model warms. The profile shows one 350 ms task on the main thread, exactly seven times the Response budget. The fix is not faster compute but different placement: a Web Worker runs the model off-thread and posts the result back. Wall-clock time is similar. User-perceived freeze disappears. A PerformanceObserver for long tasks catches the next instance before release.
A data-visualization library handles dashboards with 500 rows but stutters at 5,000. Scroll-driven layout recomputation runs on every visible row at every frame, producing 28-32 ms frames against the 16 ms Animation budget. This is the Animation cousin of Response: a per-frame budget breach caused by main-thread work. The fix is compositor-only updates through transform-based row positioning, plus virtualization for off-screen rows. Both moves depend on the Rendering Pipeline’s stage vocabulary.
An enterprise browser fork inherits an analytics SDK from a vendor partner. The SDK installs an input handler that calls localStorage.getItem and parses the result synchronously. On low-end Android, the parse runs 80 ms, so every address-bar keystroke carries an 80 ms long task. Users call the address bar “laggy”; support logs a “performance regression after Chrome 130 update” because the SDK shipped in the same release. chrome://tracing identifies the analytics SDK as the source. The vendor moves the storage read to an asynchronous interface backed by an IndexedDB-driven worker. The fork adds embedded third-party SDK review to its standing checklist.
Notes for Agent Context
Treat 50 milliseconds as the hard budget for any synchronous task running on a page’s main thread. Any generated function that could plausibly exceed it must yield through scheduler.postTask() or move into a Web Worker before code is returned.
Do not generate handlers that perform synchronous IPC, synchronous storage reads (localStorage, sessionStorage, synchronous XMLHttpRequest), or large synchronous parses (more than 100 kB of JSON or equivalent) inside input, keydown, pointerdown, or click handlers. These are the canonical paths for the antipattern.
When asked to optimize a “slow” page, instrument first. Emit a PerformanceObserver for longtask entries, log each task’s duration and attribution, run a representative interaction, and inspect the observer output before proposing a fix.
Use the 50 ms Response budget and the 16 ms Animation budget from RAIL Performance Model. Never substitute a larger number “for safety.” Never describe a function as “fast enough” without a measurement against the budget.
Related Patterns
Sources
The 50 ms Response budget and the four-part framework descend from Measure Performance with the RAIL Model on web.dev (Paul Lewis and Paul Irish, original publication 2015, kept current as the canonical model reference). The Long Tasks API and its 50 ms threshold are specified in Long Tasks API (W3C Working Draft, current). The Interaction to Next Paint metric, which grades the antipattern’s user-visible symptom against the Core Web Vitals thresholds, is documented at web.dev/articles/inp, which fixes 200 ms as the good boundary and 500 ms as the poor boundary. The remediation playbook is collected in Optimize long tasks on web.dev, which names task chunking via scheduler.postTask() and the Prioritized Task Scheduling specification as the modern primitives. The Prioritized Task Scheduling API itself is specified at the WICG Scheduling APIs explainer and shipped to Chromium via the Intent process. The Chromium scheduler’s RAILMode enumeration, which the antipattern crosses on every breach, is named in the Blink Scheduler design documentation.
Technical Drill-Down
- Long Tasks API specification (W3C) — the canonical 50 ms threshold and the
PerformanceLongTaskTiminginterface every observer-based instrumentation reads. web.dev— Optimize long tasks — the remediation playbook: yielding viascheduler.postTask(), splitting work across frames, theisInputPending()check.web.dev— Interaction to Next Paint (INP) — the field-measured user-visible metric and the good / needs improvement / poor thresholds.- WICG Scheduling APIs — Prioritized
postTask— the explainer for the priority-tagged task scheduling primitive. scheduler.postTask()on MDN — the runtime API surface, the priority enumeration, and an example yielding pattern.- Web Workers API on MDN — the off-thread execution surface and the
postMessageboundary for moving long synchronous work off the main thread. - Blink Scheduler README — the in-tree scheduler’s design notes and the
RAILModeenumeration that the antipattern crosses. - Chrome User Experience Report (CrUX) — the public dataset that aggregates field-measured INP and long-task signals across the Chrome population.