Skip to content
academia.sh

Lesson 10 / 24

Timing and the Paint Cycle

The browser's frame-preparation order and fitting an update into that order; the frame budget, the alignment drift a fixed-delay timer causes, the frame callback's rules, separating reads from writes, and splitting long work.

Contents

Observers solve when the measurement gets taken, leave open when the change gets applied. Code that updates a heading’s position while the measurement list scrolls, or grows a bar, runs against the browser’s drawing schedule if it does this at a random moment.

The browser refreshes the screen not continuously, but frame by frame. It follows a specific order in every frame, and that order is defined. Fitting the update into this order is the only way for motion to look smooth.

Anatomy of a Frame

A frame goes through these stages: accumulated input events get processed, expired timers run, frame callbacks get called, style gets calculated, layout gets done, painting gets done, and layers get composited.

Two consequences follow from this order. First, frame callbacks run before style and layout; a change made there shows on screen in the same frame. Second, the sum of all work done in a frame must not exceed the frame’s duration.

A frame’s duration depends on the screen’s refresh rate and is not the same on every device. Assuming 60 frames per second, a frame lasts 16.67 milliseconds; this shortens on faster screens. This is why code should rely on the timestamp the browser gives, rather than assuming a fixed duration.

Timer Versus Frame Alignment

A common approach is tying the update to a fixed-delay timer. This approach’s problem is that the delay gets counted from when the work finishes: a drift accumulates every round, equal to the work’s duration.

// frame.mjs — comparing a fixed-delay timer's callback to a frame-aligned one
const FRAME = 1000 / 60;              // frame duration assuming 60 frames per second (ms)
const WORK = 4;                       // duration each update takes (ms)
const STEPS = 8;

const frameNumber = (t) => Math.floor(t / FRAME);

// Timer: every round gets rescheduled 16 ms after the work finishes.
let t = 0;
const timer = [];
for (let i = 0; i < STEPS; i += 1) {
  timer.push(t);
  t += WORK + 16;
}

// Frame-aligned: the callback runs at the start of the frame, work duration does not shift the next target.
const frameAligned = Array.from({ length: STEPS }, (_, i) => i * FRAME);

const tally = (moments) => {
  const bins = new Map();
  for (const moment of moments) bins.set(frameNumber(moment), (bins.get(frameNumber(moment)) ?? 0) + 1);
  return bins;
};

console.log("step  timer(ms)        frame  frame-aligned(ms)  frame");
for (let i = 0; i < STEPS; i += 1)
  console.log(
    String(i + 1).padStart(4),
    timer[i].toFixed(2).padStart(15), String(frameNumber(timer[i])).padStart(6),
    frameAligned[i].toFixed(2).padStart(17), String(frameNumber(frameAligned[i])).padStart(6),
  );

const gaps = (moments) => {
  const bins = tally(moments);
  const last = frameNumber(moments[moments.length - 1]);
  let n = 0;
  for (let k = 0; k <= last; k += 1) if ((bins.get(k) ?? 0) === 0) n += 1;
  return { last, empty: n };
};
for (const [label, moments] of [["timer", timer], ["frame-aligned", frameAligned]]) {
  const { last, empty: n } = gaps(moments);
  console.log(`${label.padEnd(13)}: ${STEPS} updates, spread across frames 0..${last}, empty frames: ${n}`);
}
console.log("frame budget:", FRAME.toFixed(2), "ms | work:", WORK, "ms | remaining:", (FRAME - WORK).toFixed(2), "ms");
step  timer(ms)        frame  frame-aligned(ms)  frame
   1            0.00      0              0.00      0
   2           20.00      1             16.67      1
   3           40.00      2             33.33      2
   4           60.00      3             50.00      3
   5           80.00      4             66.67      4
   6          100.00      6             83.33      5
   7          120.00      7            100.00      6
   8          140.00      8            116.67      7
timer        : 8 updates, spread across frames 0..8, empty frames: 1
frame-aligned: 8 updates, spread across frames 0..7, empty frames: 0
frame budget: 16.67 ms | work: 4 ms | remaining: 12.67 ms

The drift becomes visible at the sixth step: the fifth frame gets no update, the sixth frame gets the next one. What the user sees is not a freeze but irregularity — the motion pauses for a moment. When the same number of updates gets done frame-aligned, no frame stays empty.

The fixed-delay timer’s second problem is that it also runs at moments with no frame. When a tab goes to the background, the screen does not refresh; the timer still fires, and frames that will never be seen get calculated. The frame callback does not get called in that case, and the work stops on its own.

The Frame Callback’s Rules

The frame callback is one-shot: it has to get re-registered for the next frame. An ongoing motion re-registers itself from inside the callback; stopping it means not renewing the registration, or canceling a pending one.

The callback takes the frame’s timestamp as an argument. The motion’s progress gets calculated from this timestamp: “this many pixels based on elapsed time,” instead of “one pixel per call.” The first form changes the motion’s speed when the frame rate changes; the second form is device-independent. The same timestamp gets given to every callback in that frame, so elements updated in the same frame stay consistent with each other.

The user’s motion preference is a separate rule. If reduced motion has been requested at the operating-system level, this preference can get queried on the style side and also read on the program side; if the preference is set, motion gets removed or reduced to an instant transition.

Separating Reads From Writes

A second rule is needed to keep the work done inside a frame cheap. Querying an element’s size forces every pending write to get processed right then; this is the forcing mentioned in the Observers lesson. Doing measurement and writing in sequence triggers this forcing on every round.

// read-write.mjs — the layout count forced by interleaving reads and writes
function setup() {
  return {
    bars: [40, 55, 30, 72, 61].map((w, i) => ({ name: `T-0${i + 1}`, width: w })),
    dirty: false,          // is a write pending
    layouts: 0,            // forced layout calculation count
  };
}
const read = (state, bar) => {
  if (state.dirty) { state.layouts += 1; state.dirty = false; }   // measuring forces any pending writes
  return bar.width;
};
const write = (state, bar, value) => { bar.width = value; state.dirty = true; };

// First approach: read then write for each bar.
const a = setup();
for (const bar of a.bars) write(a, bar, read(a, bar) + 10);
console.log("interleaved  :", a.layouts, "forced layouts |",
  a.bars.map((c) => c.width).join(" "));

// Second approach: all measurements first, then all writes.
const b = setup();
const measured = b.bars.map((bar) => read(b, bar));
b.bars.forEach((bar, i) => write(b, bar, measured[i] + 10));
console.log("separated    :", b.layouts, "forced layouts |",
  b.bars.map((c) => c.width).join(" "));
console.log("results match:", JSON.stringify(a.bars) === JSON.stringify(b.bars));
interleaved  : 4 forced layouts | 50 65 40 82 71
separated    : 0 forced layouts | 50 65 40 82 71
results match: true

The two approaches produce the same result; their costs differ. In the separated order, measurements get taken in one pass, writes accumulate, and layout happens once, in the frame’s own stage.

The number here is again not a performance measurement, but a call count; the real cost depends on the document and the style sheet. What stays fixed is the number of triggered calculations dropping from four to zero. The rule stands out even more as the element count grows: the separated order produces a fixed number of calculations, the interleaved order one proportional to the element count.

Splitting Long Work

A single piece of work that exceeds the frame budget makes everything else in that frame wait: input does not get processed, motion does not advance. A function that filters a thousand measurements in one go does exactly this.

The solution is splitting the work. A chunk gets processed, control gets handed back to the runtime, the next chunk gets processed on the next round. This way, input events get processed in the gaps between, and the interface stays responsive. The measure of splitting is not a fixed chunk count, but elapsed time: duration gets measured every round, and the round gets ended once the budget fills up.

Some work does not have to happen inside a frame — writing to a log, clearing a cache, preparing the next page. For these, the browser offers a callback of the idle class: once the frame finishes its work, it gets called if time remains, and reports how much time is left. Work given to the idle callback gets bounded with a timeout, against the possibility that it never runs at all.

Another solution for heavy computation is taking the work out of the main thread entirely; this is the Web Workers lesson’s subject.

Summary

  • The browser refreshes the screen frame by frame; because frame callbacks run before style and layout, a change made there shows up in the same frame.
  • A fixed-delay timer accumulates drift because it counts the delay from when the work finishes, and some frames get no update; the frame callback preserves alignment.
  • The frame callback is one-shot, takes a timestamp, and does not get called in a tab whose screen does not refresh; motion gets calculated by elapsed time.
  • Doing measurement and writing in sequence forces a layout calculation on every round; this forcing disappears once all measurements happen first and all writes happen after.
  • Work that exceeds the frame budget gets split into chunks, and control gets handed back to the runtime between chunks; deferrable work gets left to the idle callback.

Next Step

Part of the work done inside a frame is producing text: writing a unit next to a measurement value, converting a timestamp into a readable date, sorting the list by name. None of these operations are universal. The decimal separator is not the same character everywhere, the order of date fields varies by language, letter sorting depends on the alphabet, and in Turkish the dotted and dotless letter are separate letters. Hand-written formatting code cannot carry these differences. The next lesson takes up the browser’s interface that does this work according to locale, and that interface’s setup cost.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close