Layout, Paint and the Main Thread
One thread runs your JavaScript, computes layout, paints, and handles the user's tap. A 300ms task anywhere in that list means a 300ms wait everywhere else in it — which is why "the page freezes when I scroll" and "my handler is slow" are the same bug.
Frame the diagnosis
Performance work starts from a symptom and a signal — never from a resource dashboard.
One thread, four jobs
The browser main thread executes JavaScript, recalculates style, performs layout, paints, and dispatches input events. These are not parallel activities competing politely — they are queued work on one thread, so any of them running long delays all the others.
That single fact explains most frontend responsiveness bugs. A 300ms data transformation in a click handler is not just a slow handler: for those 300ms, the page cannot process a scroll, cannot run an animation frame, and cannot paint. The user perceives this as the whole application freezing, and reports it as "the page is slow" rather than as anything to do with the button they pressed.
The pipeline itself has an ordering that is worth internalising, because each stage can be skipped or forced. Changing a property that only affects compositing is dramatically cheaper than one that forces layout of the whole document — and the difference is invisible in the source code unless you know which properties do what.
Layout thrashing: the loop that forces layout N times
Browsers batch layout work: you can make many style changes and the engine will recalculate once, at a convenient moment. That optimisation is defeated the instant you *read* a geometric property, because the engine must produce a correct answer and therefore has to flush all pending changes and lay out immediately.
Interleave a read and a write in a loop and you force a full synchronous layout on every iteration. The code looks linear and innocent; the cost is quadratic in effect and shows up as one enormous long task. This is one of the few frontend problems where the fix is purely a reordering of existing statements and the improvement is often an order of magnitude.
The rule is simple to state and easy to violate accidentally through abstraction: batch all reads, then perform all writes. A helper function that reads offsetHeight inside a loop body reintroduces the problem from three layers away, which is why it is worth knowing the property names that force layout rather than relying on a lint rule alone.
1for (const row of rows) {2 // READ: forces the engine to flush pending writes and lay out now3 const h = row.offsetHeight4 // WRITE: invalidates layout again for the next iteration5 row.style.height = `${h * 2}px`6}7 8// 500 rows -> 500 forced synchronous layouts.9// Profile shows one long task; the source looks like a simple loop.1// Phase 1: read everything. No writes yet, so no flush is forced.2const heights = rows.map((row) => row.offsetHeight)3 4// Phase 2: write everything. The engine batches and lays out once.5rows.forEach((row, i) => {6 row.style.height = `${heights[i] * 2}px`7})8 9// Same result, one layout pass instead of 500.Nothing about the work changed — only the ordering. The engine can batch layout only while nothing demands an up-to-date geometric answer, and a single property read in the wrong place removes that ability entirely.
Long tasks are the unit of unresponsiveness
A task is a chunk of work the main thread runs to completion before it will look at the queue again. If a task runs for 300ms, an input event arriving 10ms in waits 290ms before it is even dispatched — that wait is the "input delay" portion of INP, and no amount of optimising the handler itself will remove it.
This reframes the fix. The goal is not making the work faster; it is making the *tasks* shorter, so the thread returns to the queue frequently enough to stay responsive. Splitting 300ms of work into ten 30ms chunks with a yield between them does slightly more total work and produces a dramatically better experience.
Work that does not touch the DOM can leave the thread entirely and run in a worker, which is the strongest version of the same idea. Work that must touch the DOM has to be chunked, scheduled, or made unnecessary. The connection to Event-Loop Lag: One Callback, Everybody Waits is exact: this is the same starvation problem a single-threaded server has, with a human watching the queue.
| Signal | Value | What it tells you | Verdict |
|---|---|---|---|
| Input delay (before) | 290ms | The tap waited for a long task already running — nothing to do with the handler | smoking gun |
| Processing time (before) | 95ms | The handler itself; the part people instinctively optimise | suspect |
| Presentation delay (before) | 35ms | Layout and paint after the handler | normal |
| INP (before) | 420ms | Dominated by waiting, not by the handler | smoking gun |
| Input delay (after chunking) | 25ms | The thread now returns to the queue every ~30ms | normal |
| INP (after chunking) | 150ms | Same total work, far better responsiveness | normal |
Key points
- JavaScript, style, layout, paint and input handling share one thread; long work in any of them delays all the others.
- Reading a geometric property forces a synchronous layout, so interleaved reads and writes turn a batched operation into one per iteration.
- INP splits into input delay, processing and presentation — input delay usually dominates and is caused by a task that was already running.
- Shortening tasks matters more than shortening total work: ten 30ms chunks beat one 300ms task, even doing slightly more work overall.
- Properties that only affect compositing skip layout and paint entirely, which is why the choice of animated property is a performance decision.
Follow the diagnosis
The causal chain, hop by hop — and the readings that invite the wrong conclusion.
- 1User → page: taps a filter control; the click event is queued.
- 2Main thread → task: a 300ms data-transform task, started 10ms earlier, is running and will not yield.
- 3Event queue → handler: the tap waits 290ms before dispatch — recorded as input delay, and unrelated to the handler's own speed.
- 4Handler → DOM: the handler reads
offsetHeightinside its update loop, forcing a synchronous layout per row. - 5Layout → paint: the resulting long task overruns the frame budget, the animation stutters, and the user reports the whole page as frozen.
- • "The handler is fast, so interaction is fine." Input delay from an unrelated running task is usually the dominant part of INP.
- • "It is a rendering problem, not a JavaScript problem." Layout and paint run on the same thread as your JavaScript; the distinction does not help the user.
- • "Adding a debounce fixed it." Debouncing reduces how often the long task runs; the task is still long when it does run.
- • "CSS animations are always cheap." Only if they animate compositor-friendly properties; animating a geometric property forces layout every frame.
- • "The profiler shows a simple loop." Check for forced synchronous layout — the cost is in the engine, triggered by a property read in that loop.
Measure, fix, validate
An optimization is not finished until the metric that motivated it has moved.
- • Long tasks: count and duration during load and during interaction, on a throttled device profile.
- • Total blocking time as an aggregate proxy for how much of the load window was unresponsive.
- • INP attribution per interaction: input delay versus processing versus presentation, so you optimise the dominant part.
- • Forced synchronous layout occurrences in a main-thread profile — most profilers flag these explicitly.
- • Frames dropped during scroll or animation, which identifies work landing inside the frame budget.
- • Break long tasks into chunks that yield to the event loop, so input can be dispatched between them.
- • Batch DOM reads and writes into separate phases to eliminate forced synchronous layout.
- • Move non-DOM computation off the main thread into a worker, which removes the contention rather than rescheduling it.
- • Animate compositor-friendly properties so frames skip layout and paint entirely.
- • Reduce the work: virtualise long lists, compute less at interaction time, and pre-compute what can be prepared before the user acts.
- • Long-task count and maximum task duration on the throttled profile, before and after — the maximum matters more than the total.
- • Field INP p75 for the affected route and device segment over a stable window, with attribution confirming input delay specifically fell.
- • Frames dropped during the interaction, which should fall if work now fits inside the frame budget.
- • A profile confirming forced synchronous layout events disappeared rather than moving to a different call site.
- • Chunking with yields increases total wall-clock work slightly and adds scheduling complexity to otherwise linear code.
- • Workers cannot touch the DOM and require serialising data across the boundary, which can cost more than the work saved for small payloads.
- • Virtualising lists adds significant complexity and breaks find-in-page and anchor links unless handled deliberately.
- • Restricting animations to compositor-friendly properties constrains what designers can express.
- • A CI budget on total blocking time and maximum task duration for key interaction flows on a pinned profile.
- • A lint rule flagging layout-forcing property reads inside loops, backed by a review note listing the properties that force layout.
- • A field alert on INP p75 per device class, since main-thread regressions do not show up in any load metric.
- • A performance review step for any new interaction handler that performs more than trivial computation.
Accuracy
Performance numbers are conditional. These are the conditions.
- WEB-SPECIFICThe main-thread model, the rendering pipeline stages and which properties force layout are browser-engine behaviours that differ between engines and versions.
- ILLUSTRATIVEThe INP attribution numbers are invented to show the characteristic split where input delay dominates. Real attribution varies per interaction.
- RUNTIME-SPECIFICWhich property reads force layout, and how aggressively the engine batches, are engine implementation details — verify against a profile rather than a remembered list.
Misconceptions
Apply it
Where the depth lives
The main thread is a cooperative scheduler with no preemption: a task that does not yield starves every other kind of work, exactly as a non-yielding process would on a cooperative OS.