Lesson 22 / 24
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.
Contents
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.
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.