---
title: 'Runtime Performance'
source: 'https://academia.sh/en/courses/frontend-quality/runtime-performance'
course: 'Frontend Quality'
language: en
updated: '2026-08-17T18:11:06+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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.

```js
// 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.
