Web Platform · Rendering Performance
Frame Budgets, Long Tasks, and Where Input Latency Comes From
How the main thread allocates work to a frame, why a 50 ms task delays the next interaction by more than 50 ms, and which scheduling primitives actually yield.
Everything that runs in a page — parsing, script, style, layout, and the callbacks that respond to a tap — shares one main thread. The event loop runs one task to completion before it can consider the next, and the rendering steps are inserted between tasks. A task that takes 200 ms therefore does not cost 200 ms of animation; it costs every frame that fell inside it, plus the input that arrived during it, which is delivered only after it finishes.
The frame as a sequence of loop iterations
The rendering steps run after a task completes, when the browser decides a frame is due; the stages they perform are set out in How a Frame Is Produced. The order within a frame is fixed: input events are dispatched first, then animation callbacks registered with requestAnimationFrame, then style, layout, and paint, then commit to the compositor. The whole sequence must finish before the display’s next refresh; at 60 Hz the interval is 16.7 ms, at 120 Hz 8.3 ms.
Two consequences follow. First, input that arrives just after the input phase of a frame waits for the next frame, so even an idle page adds up to one refresh interval of latency. Second, any script that runs during the frame competes with the rendering steps for the same 16.7 ms: a 10 ms task followed by 9 ms of layout overruns the budget and the frame is dropped.
Microtasks are not a separate slice. Promise callbacks run at the end of the current task, before the rendering steps, and they run until the microtask queue is empty. A promise chain that keeps enqueueing work can therefore delay rendering indefinitely, which is why “await” is not a yield point for frame scheduling.
The 50 ms definition and why it is conservative
The Long Tasks API reports any task on the main thread that runs longer than 50 ms. The threshold is a convention rather than a physical constant: it is roughly three 60 Hz frames, chosen so that a task flagged as long is one that a user could plausibly notice as a delay rather than as an isolated dropped frame.
The metric is crude in a useful way. A 51 ms task and a 500 ms task are both “long”, but the second blocks input for ten times as long. What the API adds is attribution: a longtask entry carries a start time, duration, and, in engines that support it, a container and attribution list naming the frame or script that occupied the thread. The Long Animation Frames API extends the idea to the whole frame, including rendering and the scripts that contributed to it, which is what makes it possible to separate “my script took 120 ms” from “my script took 20 ms and layout took 100 ms”.
Input arrives before the frame it affects
An interaction’s latency is the sum of several segments:
- the time until the input event is dispatched, which is bounded by whatever task is currently running;
- the time the handler spends before the next frame is produced;
- the rendering work for the frame that shows the result, including style, layout, and paint;
- the compositor and display pipeline, which adds at least one refresh interval.
Only the second segment belongs to the handler. A handler that runs in 30 ms can still produce a latency of hundreds of milliseconds if it runs inside a task that started earlier and is still going when the input arrives. This is why the same handler measures differently depending on what else the page is doing: event timing measures from the input’s hardware timestamp to the next paint that includes the handler’s visual result.
The metric that captures this for a whole page is Interaction to Next Paint. Its published threshold for a good rating is 200 ms, evaluated at a high percentile of interactions rather than at the average, because a single slow interaction is what users remember. The implication for engineering is that the worst tasks matter more than the typical ones.
Chunking work without changing what it observes
Splitting a long task into smaller ones is the only way to let the browser run rendering and dispatch input in between. The constraint is that the programme must remain correct when it is interrupted: state that spans chunks must be explicit, and a chunk boundary must not fall in the middle of an operation that requires a consistent tree.
async function processAll(items) {
for (let i = 0; i < items.length; i += 200) {
for (const item of items.slice(i, i + 200)) {
item.state = compute(item); // no reads of layout here
}
// Yield to the event loop, allowing a frame to run.
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
setTimeout(..., 0) is a real yield, but the HTML standard clamps the delay for deeply nested timers: after five levels of nesting, the timeout is treated as at least 4 ms. That clamp turns a tight loop of yields into a sequence of tasks with a 4 ms floor each, which is why long chunked jobs can take longer overall than one blocking task. MessageChannel posts a task without the clamp, and a scheduler API that exposes a yield primitive exists in some engines, but the portable fallback remains a macrotask.
Chunk size is chosen against the frame budget. A chunk that takes 5 ms leaves room for rendering within a 16.7 ms frame; a chunk that takes 15 ms will drop frames on slower hardware. Since the only honest way to size a chunk is to measure, a common pattern is to process a fixed batch, check elapsed time with performance.now(), and yield when a budget is exceeded.
Idle callbacks are not a rendering fix
requestIdleCallback runs work when the browser estimates it has spare time before the next frame, and it passes a deadline object whose timeRemaining() reports the estimate. It is appropriate for work that can be deferred indefinitely and can be abandoned mid-way, such as prefetching or analytics assembly. It is not appropriate for work whose result is needed for the current frame: the callback may not run at all under sustained load, and the deadline is an estimate that can shrink while the callback runs.
The safer structure is to separate work into a “must complete before this frame” phase, run inside the animation callback, and a “can be spread over many frames” phase, run in a task that yields. Mixing the two produces the familiar failure where a list renders progressively and the scroll position jumps as rows are appended, because layout runs after each chunk.
Work that cannot leave the main thread
Moving computation to a worker shortens main-thread tasks, but only computation. Style recalculation, layout, paint recording, and the dispatch of DOM events are main-thread operations: the document’s tree and its style and layout state belong to the agent that owns the document, and a worker has no access to either. A worker can compute values and post them back, which reduces the main-thread task to the DOM writes, but those writes still cost style and layout on the main thread.
The exceptions are narrow. Canvas rendering can be moved to a worker through an offscreen canvas context, where frame production happens off the main thread. Rendering of an HTML subtree cannot be moved anywhere. That asymmetry sets the ceiling on offloading: a page whose main-thread time is dominated by rendering gains nothing from a worker, and a page whose time is dominated by arithmetic can come close to the frame budget by removing that arithmetic from the thread entirely.
When the main thread is not the bottleneck
Input latency can remain high with an idle main thread if the compositor is saturated. A page with many layers, large blurred surfaces, or heavy raster work during scroll can miss frames while script is doing nothing; the diagnosis is a profiler showing an idle main thread and a busy compositor. That case is not addressed by yielding or chunking, and Compositing and Layer Promotion covers what to remove.
A related asymmetry: a long task delays input, but short tasks that arrive in a burst can also produce latency, because each task must run to completion and the input event is queued behind however many tasks are already pending. A page that enqueues a thousand 1 ms tasks has the same worst-case input delay as a page with one 1000 ms task, and it will drop fewer frames along the way. Priority matters as much as duration: the browser cannot preempt a task, so the only defence is to keep tasks short and to avoid queueing more work than the frame needs.
References
- HTML Standard — Event loops — WHATWG
- Long Tasks API — W3C
- Event Timing API — W3C
- Cooperative Scheduling of Background Tasks — W3C