Frame timing across refresh rates and visibility changes
Calculate animation progress from elapsed time, not from how many callbacks have run. Use requestAnimationFrame() to schedule visual updates, then define separately whether hidden time counts and whether every callback should produce a new displayed value.
Put time in the model
The component is an eight-second progress control. Its model stores elapsed milliseconds, the previous callback timestamp, the last update boundary, the outstanding request identifier and whether the user has started it. The progress element displays the model’s current value; it is not a counter of callbacks.
A callback adds the difference between its timestamp and the preceding timestamp. The first callback after Start or Resume establishes a new reference without adding an unknown interval. The value is clamped at 8,000 milliseconds so completion has one explicit endpoint rather than depending on landing on an exact timestamp.
This is a local progress demonstration, not a clock for a server job, financial deadline or audio transport. Its pause policy intentionally excludes time between the last callback before pausing and the first callback after resuming. If the task must follow an external deadline, use that authoritative time model instead of extending this local accumulator.
Schedule an opportunity to update
requestAnimationFrame() requests a callback before the next repaint and is a one-shot request; another callback must be requested to continue. MDN describes a callback timestamp and warns against advancing by a fixed amount per callback on higher-refresh displays 1. These documented properties motivate the model; the separate observer series supplies the bounded cadence observation below.
A fixed increment such as “add one unit every callback” couples elapsed progress to callback delivery. Our code instead treats the timestamp difference as the increment. It does not assume that every interval is equal or that the display uses a particular refresh rate. A delayed callback can therefore produce a larger progress change.
That larger change is a policy decision worth seeing. The code does not subdivide a delayed interval into simulated frames. It simply catches the displayed progress up to the accumulated time. A physics model, drawing tool or gesture recognizer may need a different integration rule. Do not use this progress indicator as evidence that every stateful simulation should accept an arbitrarily large time step.
Separate update policy from elapsed duration
The selector offers Every callback and a target of 30 updates per second. In the second mode, the code compares elapsed time with intervals of 1000 / 30 milliseconds and retains the remainder when advancing the update boundary. It skips some display assignments while continuing to account for elapsed time.
The label describes a target policy, not a promise of evenly spaced updates. Callback timing, rounding and delayed delivery can change the actual spacing. The code does not ask the display to change its refresh rate, and it does not measure the number of pictures presented to the user. An observation of completion at the value 8,000 is a state result, not proof of 30 displayed frames each second.
Both options use the same duration model. The lower-update option does not divide elapsed time or wait for a fixed callback count. Check final state separately from cadence.
How we measured it
Under the Tested conditions, an appended collector reset and started the unchanged component, then sampled its own requestAnimationFrame timestamps. We analyzed consecutive timestamps inside the declared window; this includes a short completion tail. It measures observer callback spacing, not the progress element’s assignment count. The 30-update option therefore need not produce 33.3 ms observer intervals.
The every-callback suite and 30-update suite retain raw data. The analysis uses ordinary medians and nearest-rank p95, ceil(0.95 × n); displayed values are rounded to one decimal.
| Run (8.3 s window) | Every-callback intervals | Median, ms | p95, ms | 30-policy intervals | Median, ms | p95, ms |
|---|---|---|---|---|---|---|
| 1 | 495 | 16.7 | 17.3 | 497 | 16.7 | 17.5 |
| 2 | 497 | 16.7 | 17.4 | 497 | 16.7 | 17.5 |
| 3 | 497 | 16.7 | 17.5 | 497 | 16.7 | 17.5 |
| 4 | 497 | 16.7 | 17.5 | 497 | 16.7 | 17.5 |
| 5 | 497 | 16.7 | 17.4 | 497 | 16.7 | 17.5 |
The long-animation-frame observer was supported and active. The API identifies rendering updates delayed beyond 50 ms.2 Zero received entries does not establish zero missed presentations. One earlier 30-policy suite included a hidden-page interruption; that entire suite is retained as excluded context, with none of its runs pooled here. A stop timer was up to 0.2 ms early. These small series support a repeatable record, not a statistically established policy advantage.
Make Pause cancel the outstanding request
Pause changes the running flag, cancels the most recent request, clears that identifier and resets the previous timestamp. cancelAnimationFrame() cancels a request scheduled through requestAnimationFrame() using its returned identifier 3. The flag also prevents a callback from doing work if the control is no longer running.
The displayed value can lag the accumulated value by part of an update interval in the targeted mode. Pause preserves the display instead of forcing an extra render. Resume continues from the accumulated model and may reveal that small difference at its next update. This is a declared consequence of the update policy, not evidence of lost time or a measured rendering defect.
Repeated Start clicks while running have no requested action. Completion clears the running state and keeps the final value. Starting a completed control also has no requested action until Reset restores the baseline. Those rules avoid accidentally creating multiple callback chains for one progress element.
Decide what happens when the page is hidden
The Page Visibility API reports whether a document is visible and emits a visibility-change event. Browser background throttling is separate from the application’s policy; MDN notes that most browsers stop animation-frame callbacks in background tabs 4. Do not treat a window losing focus as proof that its page became hidden.
This component explicitly pauses when it receives a hidden state while running. It does not resume automatically on return. The user must choose Start or resume, which establishes a fresh timestamp reference. That prevents a background interval from being silently added to this particular local progress task.
The excluded suite recorded a real hidden state and a paused endpoint near 5200 ms. That interruption explains its exclusion; it is not a systematic background-policy test.
The component
Original code, MIT licensed. Save this page locally. The separate demo includes steps for both update policies and explicitly separates optional tab-hide checks.
<!doctype html>
<!-- SPDX-License-Identifier: MIT -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A timestamp-based progress control</title>
<style>
body { font: 1rem/1.5 system-ui; margin: 1rem; }
progress { display: block; width: 320px; max-width: 100%; height: 2rem; margin: 1rem 0; }
button, select { padding: .5rem; margin: .25rem; }
:focus-visible { outline: 3px solid #b24d00; outline-offset: 3px; }
</style>
</head>
<body>
<label for="cadence">Update policy</label>
<select id="cadence">
<option value="0">Every callback</option>
<option value="30">30 updates per second target</option>
</select>
<button id="start">Start or resume</button>
<button id="pause">Pause</button>
<button id="reset">Reset</button>
<progress id="progress" max="8000" value="0" aria-label="Eight-second progress"></progress>
<p id="state" role="status">Ready</p>
<script>
const progress = document.querySelector("#progress");
const state = document.querySelector("#state");
const cadence = document.querySelector("#cadence");
let elapsed = 0;
let last = null;
let lastPaint = 0;
let request = null;
let running = false;
function tick(time) {
if (!running) return;
if (last !== null) elapsed = Math.min(8000, elapsed + time - last);
last = time;
const interval = cadence.value === "30" ? 1000 / 30 : 0;
if (!interval || elapsed - lastPaint >= interval || elapsed === 8000) {
progress.value = elapsed;
lastPaint = interval ? elapsed - elapsed % interval : elapsed;
}
if (elapsed < 8000) request = requestAnimationFrame(tick);
else {
running = false;
request = null;
state.textContent = "Complete";
}
}
function pause(message) {
running = false;
cancelAnimationFrame(request);
request = null;
last = null;
state.textContent = message;
}
document.querySelector("#start").onclick = () => {
if (running || elapsed === 8000) return;
running = true;
last = null;
state.textContent = "Running";
request = requestAnimationFrame(tick);
};
document.querySelector("#pause").onclick = () => pause("Paused");
document.querySelector("#reset").onclick = () => {
pause("Ready");
elapsed = 0;
lastPaint = 0;
progress.value = 0;
};
document.addEventListener("visibilitychange", () => {
if (document.hidden && running) pause("Paused while hidden; resume when ready");
});
</script>
</body>
</html>
Compare the state before comparing cadence
Start, pause partway through, and read the progress value twice while paused. Resume and let the value reach its endpoint. Reset before trying the other policy so each run begins from the same state. If you change the selector during a run, keep that action in the record; it changes the display-update rule without changing the duration model.
Keep the callback series separate from work duration and presentation evidence. The observed percentiles do not measure missed frames, actual progress assignments or interaction latency. The OS-reported display mode is a condition, not a presentation counter.
Not covered on this page
- A presented-frame rate, high-refresh-device comparison or presentation-time trace.
- Physics integration, deadline accuracy, audio synchronization or cross-window timing.
- Reduced-motion behavior, real touch or screen-reader announcements.
- Component teardown, arbitrary background policies or accessibility certification.
Sources
-
MDN, Window: requestAnimationFrame() method, living documentation. https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame (opened 24 October 2026). ↩
-
MDN, PerformanceLongAnimationFrameTiming. Living documentation, opened 24 October 2026. ↩
-
MDN, Window: cancelAnimationFrame() method, modified 21 August 2026. https://developer.mozilla.org/en-US/docs/Web/API/Window/cancelAnimationFrame (opened 24 October 2026). ↩
-
MDN, Page Visibility API, modified 30 December 2025. https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API (opened 24 October 2026). ↩
Code on this page is original and MIT licensed.
How we test: methods. Found a mistake? Corrections.