Frontendlayoutpaintmain threadlong taskslayout thrashingweb

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.

Follow the diagnosis

Frame the diagnosis

Performance work starts from a symptom and a signal — never from a resource dashboard.

Diagnostic question
The page loaded fine but feels unresponsive when I interact with it — what is occupying the main thread, and when?
Symptom
Taps and scrolls feel sticky or delayed. Animations stutter. The page is fully loaded, so no loading metric shows anything wrong.
Signal
Long-task count and duration on the main thread, plus INP attribution splitting input delay from processing from presentation. The misleading signal is any load metric, which finished before the problem starts.
SymptomSignalMeasurementHypothesisEvidenceRoot CauseChangeValidationRegression Check

One thread, four jobs

Web-specific · Browser main-thread model; compositor and worker threads exist but do not run application JavaScript by default

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.

queued behind running workif styles changedif geometry changedif pixels changedtransform/opacity only: skips layout + paintInput eventJavaScriptRecalculate styleLayout (reflow)PaintComposite
UserLLMAgentToolDataDecisionHumanGuardrail

Layout thrashing: the loop that forces layout N times

Web-specific · Browser layout batching; which property reads force synchronous layout is engine-specific

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.

Read, write, read, write — forces layout every iteration
1for (const row of rows) {
2 // READ: forces the engine to flush pending writes and lay out now
3 const h = row.offsetHeight
4 // WRITE: invalidates layout again for the next iteration
5 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.
Batch reads, then batch writes — one layout
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

Web-specific · Browser main-thread task scheduling and the Long Tasks API

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.

INP attribution for a slow interaction, before and after chunkingILLUSTRATIVE
SignalValueWhat it tells youVerdict
Input delay (before)290msThe tap waited for a long task already running — nothing to do with the handlersmoking gun
Processing time (before)95msThe handler itself; the part people instinctively optimisesuspect
Presentation delay (before)35msLayout and paint after the handlernormal
INP (before)420msDominated by waiting, not by the handlersmoking gun
Input delay (after chunking)25msThe thread now returns to the queue every ~30msnormal
INP (after chunking)150msSame total work, far better responsivenessnormal

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.

  1. 1
    User → page: taps a filter control; the click event is queued.
  2. 2
    Main thread → task: a 300ms data-transform task, started 10ms earlier, is running and will not yield.
  3. 3
    Event queue → handler: the tap waits 290ms before dispatch — recorded as input delay, and unrelated to the handler's own speed.
  4. 4
    Handler → DOM: the handler reads offsetHeight inside its update loop, forcing a synchronous layout per row.
  5. 5
    Layout → paint: the resulting long task overruns the frame budget, the animation stutters, and the user reports the whole page as frozen.
What this evidence makes people conclude — wrongly
  • "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.

How to measure it
  • • 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.
What actually fixes it
  • • 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.
How you know it worked
  • • 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.
What it costs
  • • 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.
Stop it coming back
  • 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.

What these numbers depend on
  • 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

Claim
“Slow interaction means the event handler is slow.”
Reality
Input delay — waiting for an unrelated task already running — is usually the largest component. The handler can be optimal and the interaction still terrible.
Claim
“Rendering performance is separate from JavaScript performance.”
Reality
They share one thread. Style, layout, paint and your code queue against each other, which is why a data transform can stutter an animation.
Claim
“Batching DOM writes is a micro-optimisation.”
Reality
Interleaving reads and writes forces a synchronous layout per iteration. Reordering the same statements routinely turns one long task into a short one.

Apply it

Where the depth lives

Operating systems
Run-to-completion scheduling and starvation

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.