---
title: 'Performance Recording'
source: 'https://academia.sh/en/courses/browser-platform/performance-recording'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:11+00:00'
license: 'CC BY-SA 4.0'
---

# Performance Recording

The main thread's breakdown of work over time; the distinction sampling produces between self time and total time, the long-task and frame-budget calculation, counting layout thrashing, and traps in interpreting a recording.

The Network panel says when a request finished, not why the page still stays frozen after
the response arrives. If the measurement list downloaded but the screen updates late, the
cause is not the network but work queued on a single thread.

This lesson covers the **performance recording** that produces this queue's breakdown
over time, and how to read it.

## What the Recording Collects

The recording is not a single list, it is several traces laid on the same time axis.

The **main thread trace** is the densest: it carries when every task started, how long it
took, and which calls it contained. The **rendering trace** shows which frames were
produced on time. The **network trace** gives requests' place on the time axis and is the
previous lesson's list aligned to time. The **mark and measure trace** carries labels
coming from the code the recording ran.

The traces sharing an axis is the recording's real value: the gap between a request
finishing and the screen changing is only visible once two traces sit side by side.

## One Queue: The Main Thread

Most of the work the browser runs per page shares a single queue: HTML parsing, style
calculation, layout, paint preparation, and every script. The model established in the
Event Loop lesson of the Asynchronous JavaScript and Runtime course finds its visual
counterpart here; what the recording shows is that queue made real.

A single queue has two consequences. While a task runs, user input cannot be processed;
a click is recorded but the response is delayed. And while a task runs, no new frame can
be produced; animation stops.

## Sampling and the Flame Chart

The main thread trace is mostly collected by **sampling**: the recorder captures the
current call stack at fixed intervals. The flame chart is derived from these samples.
Horizontal width is duration; **vertical depth is not time, it is call nesting.**

Two numbers are read from the chart, and confusing them leads to the wrong optimization.

```js
// sampling.mjs — self time and total time computation from sample stacks
const INTERVAL = 1; // ms: the sampler records the call stack every 1 ms

// Every row is the call stack at that moment (outermost to innermost).
const samples = [
  ["root", "drawMeasurements"],
  ["root", "drawMeasurements", "buildRow"],
  ["root", "drawMeasurements", "buildRow"],
  ["root", "drawMeasurements", "buildRow", "formatDate"],
  ["root", "drawMeasurements", "buildRow", "formatDate"],
  ["root", "drawMeasurements", "buildRow", "formatDate"],
  ["root", "drawMeasurements", "buildRow"],
  ["root", "drawMeasurements", "computeThreshold"],
  ["root", "drawMeasurements", "computeThreshold"],
  ["root", "writeToLog"],
];

const total = new Map();
const self = new Map();
const add = (map, name) => map.set(name, (map.get(name) ?? 0) + INTERVAL);

for (const stack of samples) {
  for (const name of new Set(stack)) add(total, name); // total time for every frame
  add(self, stack[stack.length - 1]);                  // self time only for the innermost
}

console.log("function".padEnd(16) + "total".padStart(8) + "self".padStart(8));
for (const [name, t] of [...total].sort((a, b) => b[1] - a[1]))
  console.log(name.padEnd(16) + `${t} ms`.padStart(8) + `${self.get(name) ?? 0} ms`.padStart(8));

// Long task threshold and frame budget
const LONG_TASK = 50;      // ms
const FRAME_BUDGET = 1000 / 60;

const tasks = [
  { name: "navigation handling", duration: 132 },
  { name: "measurement list render", duration: 68 },
  { name: "input handler", duration: 12 },
  { name: "scroll handler", duration: 23 },
];

console.log("\ntask".padEnd(24) + "duration".padStart(8) + "  long task?  dropped frames");
for (const t of tasks) {
  const dropped = Math.max(0, Math.floor(t.duration / FRAME_BUDGET));
  console.log(
    t.name.padEnd(24) + `${t.duration} ms`.padStart(8) +
    (t.duration > LONG_TASK ? "  yes" : "  no").padEnd(15) + dropped);
}
console.log(`\nframe budget: ${FRAME_BUDGET.toFixed(2)} ms (60 frames per second)`);
```

```
function           total    self
root               10 ms    0 ms
drawMeasurements    9 ms    1 ms
buildRow            6 ms    3 ms
formatDate          3 ms    3 ms
computeThreshold    2 ms    2 ms
writeToLog          1 ms    1 ms

task                   duration  long task?  dropped frames
navigation handling       132 ms  yes          7
measurement list render    68 ms  yes          4
input handler              12 ms  no           0
scroll handler             23 ms  no           1

frame budget: 16.67 ms (60 frames per second)
```

**Total time** is a function's own duration plus everything it calls. **Self time** is
only the time spent in that function's own body. The root frame's total time is the whole
recording, and its self time is zero: it does no work, it only calls.

An optimization decision is made by looking at self time. Trying to speed up a function
with a large total time is wasted effort if the time is actually spent in a sub-function
it calls. In the output, `drawMeasurements` totals nine milliseconds but spends only one
in its own body; the real cost sits in `buildRow` and the formatting function it calls.

## Long Task and Frame Budget

The second table places two thresholds side by side.

The **long task** threshold separates tasks that occupy the main thread for more than
fifty milliseconds. Its reasoning is input response: a delay of this order becomes
noticeable when the user presses a button.

**Frame budget** is derived from the screen's refresh rate. The example assumes sixty
frames per second and brings the budget to roughly seventeen milliseconds; a screen with a
different refresh rate changes the budget, not the shape of the calculation. Any task that
does not fit the budget blocks frame production for its duration — the last column counts
this.

The two thresholds measure different problems. A fifty-millisecond task is not a long
task but still drops three frames. Scroll and animation smoothness is judged by frame
budget, click response by the long-task threshold.

## Layout Thrashing

The most common pattern in a recording is layout computations scattered through a
script. The reason is defined: when a measurement is asked for, the browser has to
compute layout **immediately** if changes are pending. When reading a measurement and
writing a style alternate, this computation is forced again on every turn.

```js
// thrashing.mjs — how many layout computations read-write ordering produces
function engine() {
  let dirty = false;
  let forcedComputation = 0;
  return {
    get forcedComputation() { return forcedComputation; },
    write() { dirty = true; },                       // invalidates layout
    read() { if (dirty) { forcedComputation += 1; dirty = false; } }, // reading a measurement forces the computation
    frameEnd() { if (dirty) { dirty = false; return 1; } return 0; }, // normal computation
  };
}

function run(sequence) {
  const m = engine();
  for (const op of sequence) m[op]();
  const frameEndComputation = m.frameEnd();
  return { forced: m.forcedComputation, frameEnd: frameEndComputation };
}

const ROWS = 5;
const interleaved = [];
for (let i = 0; i < ROWS; i++) interleaved.push("read", "write"); // read then write for every row

const batched = [
  ...Array.from({ length: ROWS }, () => "read"),
  ...Array.from({ length: ROWS }, () => "write"),
];

for (const [name, sequence] of [["read-write interleaved", interleaved], ["all reads first", batched]]) {
  const s = run(sequence);
  console.log(
    `${name.padEnd(22)} forced layout computations: ${s.forced}` +
    `   computation at frame end: ${s.frameEnd}`);
}
```

```
read-write interleaved forced layout computations: 4   computation at frame end: 1
all reads first        forced layout computations: 0   computation at frame end: 1
```

The two orderings do the same work and produce the same result; what differs is how many
computations they trigger. The interleaved sequence forces layout on every read but the
first. The reads-first sequence forces none; a single computation happens at frame's end
on the browser's own schedule.

In a recording, this pattern shows up as layout work embedded inside a script task, and
tools flag it separately as a warning. The fix is always the same shape: **batch reads,
then batch writes.** In a five-row list the difference is trivial; in a hundred-row
measurement table it eats the entire frame budget.

## Marking Your Own Code

Sampling does not reliably catch short, infrequent code. A function never appearing in
the recording does not mean it never ran; it may have fallen between two samples.

The remedy is having the code announce its own boundaries. A **mark** is placed at a
point; the span between two marks is named a **measure**, and these named spans appear in
their own trace in the recording. This pair, defined in the Performance Profiling lesson
of the Asynchronous JavaScript and Runtime course, ties unnamed tasks in the recording to
meaningful names.

Marks also carry a benefit of crossing asynchronous boundaries: the span between a data
request starting and the list being rendered does not pass inside a single task, so it
cannot be read from the flame chart; placed as a named span, it becomes readable.

## Traps in Interpreting a Recording

Three traps come up often.

**Sampling is not exact.** Small numbers are affected by the sample interval; a
two-millisecond difference can be noise. Decisions should be made on magnitudes that are
clear multiples of the sample interval.

**Measuring affects the result.** A page runs slower while recording. Absolute durations
are not meaningful — the ratio between two recordings taken under the same conditions is.

**Development output is not production output.** A recording of unminified output
carrying extra checks does not represent what the user encounters. Measurement is done
with a production build; function-name readability is a separate matter, solved with
source maps.

## Summary

- A performance recording lays several traces on the same time axis; its value is that
  gaps between traces become visible.
- The main thread is a single queue: parsing, style, layout, paint, and every script share
  it.
- In the flame chart, width is duration, depth is call nesting; optimization decisions are
  made on self time, not total time.
- The long-task threshold measures input response, frame budget measures smoothness; they
  point to different problems.
- Interleaving measurement reads and style writes forces layout computations; batching
  reads ahead of writes drops the forced count to zero.
- Sampling misses short code, recording slows down what it measures, and development
  output does not represent production behavior.

## Next Step

Performance recording gives a breakdown of a moment: what happened in the few seconds the
recording was open. Some problems do not fit this window. If the measurement page slows
down the longer it stays open, growing heavier on every refresh of the list, no single
recording shows a long task for it; what accumulates is memory that grows over time and is
never released. Its measure is not duration but the sum of objects the program can still
reach. The next lesson covers the tool that measures this sum and how to find the leak.
