Lesson 05 / 27
Runtime Performance
Measuring long tasks on the main thread with total blocking time, the measured effect of chunking work, layout thrashing scaling with row count, and ways to avoid painting what is not visible.
Contents
Once the network side is arranged, bytes arrive on time and a repeat visit makes almost no request. The application can still feel heavy: the station list stutters while scrolling, a letter typed into the filter box appears on screen late, the page may freeze for a moment when the date range changes.
None of these problems are in the network. All of them come from downloaded code running in a single queue. The Performance Recording lesson in the Browser and the Web Platform course took up the tool that makes this queue visible; this lesson takes up how to drain it.
Long Tasks and Total Blocking Time
The main thread is a queue of tasks. Once a task starts, it cannot get split: for as long as it runs, user input cannot get processed and no frame can get produced. A task exceeding fifty milliseconds counts as a long task, and the threshold’s justification is input response.
The metric is not the count of long tasks but the sum of the portions that exceed the threshold.
// blocking.mjs — total blocking time and the same work chunked into pieces const LONG_TASK = 50; // ms const tasks = [ { name: "document parsing", duration: 42 }, { name: "bundle evaluation", duration: 186 }, { name: "station list construction", duration: 128 }, { name: "measurement formatting", duration: 61 }, { name: "input handler", duration: 18 }, { name: "scroll handler", duration: 33 }, ]; console.log("task".padEnd(28) + "duration".padStart(10) + "blocking".padStart(12)); let totalBlocking = 0; for (const t of tasks) { const blocking = Math.max(0, t.duration - LONG_TASK); totalBlocking += blocking; console.log(t.name.padEnd(28) + `${t.duration} ms`.padStart(10) + `${blocking} ms`.padStart(12)); } console.log("-".repeat(50)); console.log("total blocking time".padEnd(28) + `${totalBlocking} ms`.padStart(22)); // If the same work gets split into pieces not exceeding 50 ms, blocking drops to zero. const chunked = tasks.flatMap((t) => { const pieces = Math.ceil(t.duration / LONG_TASK); return Array.from({ length: pieces }, () => t.duration / pieces); }); const chunkedBlocking = chunked.reduce((t, s) => t + Math.max(0, s - LONG_TASK), 0); console.log(`if the same work gets split into ${chunked.length} pieces: ${chunkedBlocking} ms of blocking`); console.log(`total work duration unchanged: ${tasks.reduce((t, g) => t + g.duration, 0)} ms`);
task duration blocking document parsing 42 ms 0 ms bundle evaluation 186 ms 136 ms station list construction 128 ms 78 ms measurement formatting 61 ms 11 ms input handler 18 ms 0 ms scroll handler 33 ms 0 ms -------------------------------------------------- total blocking time 225 ms if the same work gets split into 12 pieces: 0 ms of blocking total work duration unchanged: 468 ms
The task durations are the computation’s input; what gets computed is the metric itself. Of the 468 milliseconds of work, 225 milliseconds is blocking. The 42-millisecond parsing task does not count at all, because it sits under the threshold — the metric measures not the entirety of the work but the excess the user would wait through.
The last two lines say what matters: when the same work gets split into pieces not exceeding fifty milliseconds, blocking drops to zero and the total work duration does not change at all. What gets done is not reducing the work — it is inserting exit points in between.
Measuring Chunking
What the model claims can be measured. The following program does the same work three different ways and, for each, records how long the event loop stays blocked using a responsiveness probe.
// chunking.mjs — measuring the same work done in one chunk versus chunked // Responsiveness probe: reschedules itself every turn and records its delay. function startProbe(log) { let running = true; let previous = performance.now(); const turn = () => { if (!running) return; const now = performance.now(); log.push(now - previous); previous = now; setTimeout(turn, 0); }; setTimeout(turn, 0); return () => { running = false; }; } const ROWS = 300_000; function rowText(i) { // Some work per row: producing a formatted measurement line. const temperature = -12 + ((i * 37) % 240) / 10; const timestamp = new Date(Date.UTC(2026, 0, 1, 0, 0, i % 60)).toISOString(); return `north-slope-${String(i).padStart(5, "0")} ${timestamp} ${temperature.toFixed(1)} C`.length; } async function inOneChunk() { let total = 0; for (let i = 0; i < ROWS; i++) total += rowText(i); return total; } async function chunked(chunkSize) { let total = 0; for (let i = 0; i < ROWS; i++) { total += rowText(i); if ((i + 1) % chunkSize === 0) await new Promise((resolve) => setImmediate(resolve)); } return total; } async function measure(name, work) { const delays = []; const stop = startProbe(delays); await new Promise((resolve) => setTimeout(resolve, 20)); // let the probe warm up delays.length = 0; const start = performance.now(); const result = await work(); const elapsed = performance.now() - start; await new Promise((resolve) => setTimeout(resolve, 20)); // let the final poll get recorded stop(); const longest = Math.max(...delays); return { name, elapsed, longest, result, turns: delays.length }; } const measurements = [ await measure("in one chunk", inOneChunk), await measure("2000-row chunks", () => chunked(2000)), await measure("500-row chunks", () => chunked(500)), ]; console.log("--- same work, different chunking (durations depend on the machine) ---"); console.log("approach".padEnd(24) + "total".padStart(10) + "longest block".padStart(19) + "polls".padStart(9)); for (const m of measurements) { console.log(m.name.padEnd(24) + `${m.elapsed.toFixed(1)} ms`.padStart(10) + `${m.longest.toFixed(1)} ms`.padStart(19) + String(m.turns).padStart(9)); } const baseline = measurements[0]; console.log("\nratio of longest block to the single chunk:"); for (const m of measurements) { console.log(` ${m.name.padEnd(24)} ${(m.longest / baseline.longest).toFixed(3)}`); } console.log(`all approaches produced the same result: ${new Set(measurements.map((m) => m.result)).size === 1}`);
--- same work, different chunking (durations depend on the machine) --- approach total longest block polls in one chunk 123.6 ms 124.9 ms 16 2000-row chunks 126.6 ms 2.1 ms 141 500-row chunks 130.7 ms 1.7 ms 146 ratio of longest block to the single chunk: in one chunk 1.000 2000-row chunks 0.017 500-row chunks 0.014 all approaches produced the same result: true
The durations depend on the machine and its load; they vary on every run. What does not change are the ratios. The total duration stays close across the three approaches — chunking does not reduce the work, and it even adds a small per-turn overhead. The longest block, though, drops by roughly two orders of magnitude: the single block, measured here at about a hundred and twenty-five milliseconds, spreads into blocks of only a couple of milliseconds.
The poll count shows why. In the single-chunk approach, control never returns to the queue while the work runs; in the chunked approach, control returns after every chunk, and waiting input can get processed.
This measurement was taken not in a browser but in a single-threaded runtime; it
correctly models the main thread’s behavior, but the exit point in a browser is not
setImmediate. Its browser counterpart is a wait or an idle-callback that hands control
back to the event loop.
Chunking also has a limit: the smaller the chunk, the larger the per-turn overhead grows. In this measurement, the 500-row chunks take a longer total than the 2000-row chunks. The right chunk size is not “as small as possible” — it is the largest chunk that fits inside the frame budget.
Taking Work Out of the Queue Entirely
Chunking keeps the work in the main queue and allows interruption. A second approach is to take the work out of the queue entirely.
Workers, introduced in the Browser and the Web Platform course, run on a separate thread and never occupy the main queue. The work they suit is clear: pure computation, parsing, compression, filtering and sorting a large dataset. The work they do not suit is equally clear — access to the document tree. Summarizing tens of thousands of measurements on the measurement detail page can move to a worker; the result returns to the main queue only as the rows to paint.
The cost is data transfer. Message passing requires copying, and on large arrays this copying can eat the gain; transferable objects exist for this reason.
Layout Thrashing Scales with Row Count
The Animation Performance lesson in the Layout Systems and Responsive Design course defined forced synchronous layout: a write invalidates layout, and a subsequent measurement read forces the computation immediately. Ordering reads and writes this way repeats the computation on every turn, and this gets called layout thrashing.
What got counted there was the number of computations. The question here is what that number does as the list grows.
// thrashing.mjs — how layout thrashing scales with row count const FRAME_BUDGET = 1000 / 60; // ms, assuming 60 frames per second const COMPUTE_COST = 0.05; // ms: the assumed cost of a single layout computation const VISIBLE_ROWS = 20; // rows that fit in the viewport // Three approaches: how many layout computations does each force for N rows? const approaches = { "read-write inside the loop": (n) => Math.max(0, n - 1), "reads then writes": () => 0, "only visible rows": (n) => Math.max(0, Math.min(n, VISIBLE_ROWS) - 1), }; console.log("rows".padStart(7) + Object.keys(approaches).map((a) => a.padStart(30)).join("")); for (const n of [10, 50, 200, 1000, 5000]) { const cells = Object.values(approaches).map((f) => { const computations = f(n); const duration = computations * COMPUTE_COST; return `${computations} computations / ${duration.toFixed(1)} ms`.padStart(30); }); console.log(String(n).padStart(7) + cells.join("")); } console.log(`\nframe budget: ${FRAME_BUDGET.toFixed(2)} ms`); console.log("how many frame budgets get consumed:"); console.log("rows".padStart(7) + Object.keys(approaches).map((a) => a.padStart(30)).join("")); for (const n of [10, 50, 200, 1000, 5000]) { const cells = Object.values(approaches).map((f) => (f(n) * COMPUTE_COST / FRAME_BUDGET).toFixed(1).padStart(30)); console.log(String(n).padStart(7) + cells.join("")); } console.log("\nthe compute cost is an assumption; when it changes, the columns' ratios do not.");
rows read-write inside the loop reads then writes only visible rows
10 9 computations / 0.5 ms 0 computations / 0.0 ms 9 computations / 0.5 ms
50 49 computations / 2.5 ms 0 computations / 0.0 ms 19 computations / 1.0 ms
200 199 computations / 10.0 ms 0 computations / 0.0 ms 19 computations / 1.0 ms
1000 999 computations / 50.0 ms 0 computations / 0.0 ms 19 computations / 1.0 ms
5000 4999 computations / 250.0 ms 0 computations / 0.0 ms 19 computations / 1.0 ms
frame budget: 16.67 ms
how many frame budgets get consumed:
rows read-write inside the loop reads then writes only visible rows
10 0.0 0.0 0.0
50 0.1 0.0 0.1
200 0.6 0.0 0.1
1000 3.0 0.0 0.1
5000 15.0 0.0 0.1
the compute cost is an assumption; when it changes, the columns' ratios do not.
On a ten-row list there is no difference between the three approaches; this is why the problem stays invisible during development. At five thousand rows, the first approach consumes fifteen frame budgets — meaning no frame gets produced for a quarter of a second.
The two solutions differ in character. Batching reads and writes drops the cost to zero and is independent of list size; processing only the visible rows fixes the cost, and it does not change no matter how large the list grows. The second leaves the thrashing itself in place but cuts off its scaling.
Not Painting What Is Not Visible
The idea of processing only what is visible is not limited to layout thrashing; it applies to the browser’s entire rendering pipeline.
Virtualization puts only the list’s visible portion into the document tree. Nodes get reused as the list scrolls. The cost is that content absent from the tree is also absent from in-page search and from the accessibility tree.
Content visibility approaches the same goal without breaking the markup: the
content-visibility property lets paint and layout work get skipped while a box is
off-screen. The skipped box still has to occupy space; otherwise the scrollbar jumps as
content comes into view. This is why an estimated size gets given along with the
declaration.
Containment, in turn, keeps a box’s effect confined to its own boundaries. The
contain declaration, introduced in the Animation Performance lesson, prevents a change
inside a card from spreading its layout computation across the entire page.
Interaction Delay’s Three Components
Interaction to next paint is a single number, but it is the sum of three separate delays, and their fixes differ.
Input delay is the time between the user interaction and the handler starting. Its cause is another task running at that moment. Its fix is chunking or moving the work to a worker; speeding up the handler does not touch this portion.
Processing time is the handler’s own duration. Its fix is shrinking the handler and deferring work that can be deferred: make the change the user sees immediately, leave logging and analytics for later.
Presentation delay is the time between the handler finishing and the frame getting produced. Its cause is the size of the layout and paint work the handler triggers. Its fix is shrinking the area that gets updated.
A recording showing which component grew also tells you which fix not to try. An optimization done without measurement often gets applied to the wrong component.
Summary
- Total blocking time is the sum of the portions of long tasks that exceed the fifty-millisecond threshold; chunking the work can drop this metric to zero without changing the total duration.
- Measurement confirms this: when the same work gets chunked, the longest block drops by roughly two orders of magnitude while the total duration stays close; the smaller the chunk, the larger the per-turn overhead grows.
- Workers take work out of the main queue entirely; their limits are not being able to access the document tree and the cost of copying data.
- Layout thrashing’s cost grows linearly with row count; batching drops the cost to zero, working with only the visible rows fixes it.
- Interaction to next paint is the sum of input delay, processing time, and presentation delay, and each component’s fix is different.
Next Step
Five lessons defined the metrics, shortened the critical path, sized the assets, tuned the network layer, and drained the runtime. All of this shares one gap: every improvement was made once. When a new feature gets added, the bundle grows; when a new image gets placed, the first screen’s weight increases; when a library enters the shared chunk, the repeat-visit gain erodes. This erosion needs to get noticed at the next release, not months later through a complaint. The next lesson ties the metrics to a written budget and builds a checker that compares two build outputs to catch regressions.
To keep your progress and take notes, Log in
My notes
Log in to take notes.