useFps
A frame-rate probe that reports rolling fps over a sliding window of requestAnimationFrame timestamps, counts jank frames, and pauses instead of reporting zero while the tab is hidden.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-fps.jsonPrompt
Build a React + TypeScript "useFps" hook (no dependencies beyond React; uses
requestAnimationFrame / cancelAnimationFrame and document.visibilityState).
It is a development instrument: it measures the page's frame rate, it does not
drive animation.
Contract
- `useFps(options?: UseFpsOptions): UseFpsResult`.
- `UseFpsOptions = { windowMs?: number = 1000; updateMs?: number = 250;
jankThresholdMs?: number = 50; autoStart?: boolean = true;
onJank?: (frame: FpsJankFrame) => void }`.
- `FpsJankFrame = { durationMs; at; missedFrames }`:
- `durationMs` — the gap between the two rAF timestamps bracketing the frame.
- `at` — the DOMHighResTimeStamp of the frame that ended the stall, on the
same clock as `performance.now()`, so it can be diffed against the
consumer's own marks to find the work that caused it.
- `missedFrames` — `max(0, round(durationMs / frameBudget) - 1)`. Zero is a
real answer, not a missing value.
- `UseFpsResult = { fps; minFps; maxFps; jankCount; lastJank; worstFrameMs;
sampleCount; status; isMeasuring; start; stop; reset }`:
- `fps: number | null` — the rolling rate, one decimal, `null` before the
first complete reading. HELD, never cleared, when measurement stops or the
tab goes away.
- `minFps` / `maxFps` — extremes across published readings since mount or the
last `reset()`.
- `jankCount`, `lastJank`, `worstFrameMs` — the stall record; session totals
that survive a pause and are cleared only by `reset()`.
- `sampleCount` — how many timestamps the window is holding, i.e. the sample
size behind `fps`.
- `status: "idle" | "measuring" | "hidden"`.
- `start` / `stop` / `reset` keep one identity for the component's lifetime,
so they are safe in dependency arrays and on memoized children.
- Export `FpsStatus`, `FpsJankFrame`, `UseFpsOptions`, `UseFpsReadings` (the
measured half) and `UseFpsResult`.
Behavior
- Sampling. One rAF loop; every frame appends its timestamp to a Float64Array
ring buffer preallocated from the window size:
capacity = ceil(windowMs / 1000 * 240) + 2
Nothing is allocated per frame — a measurement tool that produces garbage
every 16 ms measures its own litter. A display faster than 240 Hz simply
overwrites the oldest slot, which shortens the effective window without
making the rate wrong (see the next point).
- The rate, per publish:
oldest, newest = ends of the retained window
span = newest - oldest
fps = round(((count - 1) * 1000 / span) * 10) / 10
`count - 1`, not `count`: N timestamps bracket N-1 intervals, and using
`count` reads about 6% high at 60 Hz over a one-second window. Dividing by
the RETAINED span rather than by `windowMs` is what makes a fresh run correct
within two frames instead of ramping up from a fake near-zero while the
window fills. Rounding to one decimal also means a perfectly steady display
republishes an identical object, which the equality guard then skips.
- Eviction: drop samples older than `windowMs`, but never go below two. At
0.5 fps with a one-second window every sample is stale, and "no reading" is
a worse answer than "0.5".
- Publish cadence. The loop runs at display rate; `setState` runs at most every
`updateMs`. This is the point of the hook — a probe that re-renders its
consumer sixty times a second measurably lowers the number it is displaying.
Build the next readings object OUTSIDE the state updater (updaters must be
pure and StrictMode calls them twice), compare it field by field against a
ref mirroring the last published value, and skip the update when nothing
changed.
- Jank. Per frame, `delta = time - previousTime`. When `delta >=
jankThresholdMs`: increment `jankCount`, record `lastJank`, and call `onJank`
IMMEDIATELY — not on the publish cadence, because a stall you hear about
250 ms later can no longer be correlated with the interaction that caused it.
Otherwise the frame is healthy and feeds the budget estimate:
frameBudget = budget === null ? delta : budget + (delta - budget) * 0.1
A moving average of healthy frames converges on the display's real interval
(16.7 ms at 60 Hz, 8.3 ms at 120 Hz); the MINIMUM delta would be skewed by
the occasional pair of callbacks that fire back to back, and a hard-coded
60 Hz would overstate `missedFrames` by 2x on a 120 Hz screen. Until one
healthy frame exists, assume 1000/60.
- Hidden tab. Subscribe to `visibilitychange` through `useSyncExternalStore`
(subscribe / getSnapshot reading `document.hidden` / getServerSnapshot
returning false) rather than mirroring it into state from an effect: the
value is then already correct on the render that mounts into a background
tab, and there is no setState in an effect body. While hidden the loop is
cancelled, `status` is `"hidden"`, and the last reading is held. Browsers
stop calling rAF in a background tab anyway, so measuring across the switch
would invent a collapse that never happened. On return the window is cleared
and the timing baseline dropped, so the multi-second gap is never counted as
a jank frame and never averaged into the rate.
- Start / stop. `stop()` writes a `measuringRef` synchronously AND sets state;
the tick reads that ref before recording anything, so `stop()` called from
inside `onJank` ("freeze on the first stall") takes effect on that frame
rather than a render or two later. `start()` is the mirror image and is
idempotent. With `autoStart: false` no frame is scheduled and the ring
buffer is not even allocated, so an unopened debug panel costs nothing.
Stopping HOLDS the readings; only `reset()` clears them, and `reset()`
neither starts nor stops the loop.
- Degenerate inputs, treated in two distinct ways. Clamp what is impossible:
`windowMs` outside 100-10000 (the buffer is sized from it, so an unbounded
window is an unbounded allocation), `updateMs` below 50 (below that the
cadence stops being a cadence), `jankThresholdMs` below 1; `undefined`, NaN
and Infinity all fall back to the default. OBEY what is merely wrong: a
4 ms jank threshold marks every ordinary frame as jank, and the honest
response is to say so — `missedFrames: 0` reports that none of those frames
actually skipped anything. Silently repairing a bad budget hides the bug.
- Options reach the loop through refs, never through the dependency array: a
live window/threshold control must not restart the very measurement it is
adjusting. Changing `windowMs` reallocates the buffer and restarts the
window, but deliberately keeps the timing baseline, so jank detection and the
budget estimate carry across uninterrupted.
- The loop-owning effect depends on `[isMeasuring, documentHidden]` only, and
its cleanup cancels the queued frame. `tick` queues the NEXT frame before
doing any work, so a throwing `onJank` cannot permanently kill the loop and
the cleanup always has a live id to cancel. Under StrictMode's mount →
cleanup → mount exactly one loop survives, and the session counters (which
live in refs) continue rather than double-count. Nothing reads the clock,
`document` or rAF during render, so the hook is safe in a server-rendered
tree and the first client render matches the server.
Rendering & styling
- The hook renders nothing; the consumer owns all output. A typical readout is
a big `tabular-nums` figure so digits do not shift width four times a second,
a `bg-muted` track with a `bg-primary` fill for the meter, and
`text-muted-foreground` labels. Scale the meter to `max(60, maxFps)` rather
than a hard-coded 60, or a 120 Hz display sits pinned at the top forever.
- ARIA contract. The fps figure must NOT sit inside an `aria-live` region — a
value that changes four times a second floods a screen reader. Announce only
what changes meaningfully, in one `role="status"` line per panel: measuring /
stopped / paused because the tab is hidden. Dim the figure
(`text-muted-foreground`) and label it "held" whenever `status` is not
`"measuring"`, so a frozen number is never mistaken for a live one.
- Keyboard map. Start/stop is a real `button`, activated by the native
Space/Enter; a load or overlay toggle is a `button` carrying `aria-pressed`.
Never natively `disabled` on a control the user may be focused on — the
browser drops focus to `body`; use `aria-disabled` plus a guard in the
handler. Every control has a visible `focus-visible` ring.
- prefers-reduced-motion: the meter is the only decoration, so gate its width
transition on `motion-safe:` and let the bar snap. The figures, the jank
counters and the status line are information and must stay live and correct
with motion off; the probe never depends on an animation running.
- Ship it behind a flag. This is a dev tool: it holds an rAF loop open, it
perturbs what it measures, and a frame-rate number on screen changes what
people report. Mount it in a debug overlay, in internal builds, or behind a
query parameter.
Customization levers
- Cadence and window: `updateMs` trades readout latency for re-renders (1000
for a calm panel, 100 for hunting a stutter); `windowMs` trades noise for
responsiveness (200 catches a single hitch, 3000 gives a number a human can
read). They are independent — a long window with a fast cadence is a smooth
value that still updates promptly.
- Threshold policy: `jankThresholdMs` is a budget, so set it from the target
device (50 for the long-task definition, 34 for "two frames at 60 Hz", 17
for "any missed frame at all"), and route `onJank` to your logger, a toast,
a `performance.mark()`, or a `stop()` that freezes the panel on the first
stall.
- Extra statistics: the ring buffer already holds every timestamp, so a
percentile (the 1% low that gamers quote) or a histogram of frame durations
is a loop over the retained samples at publish time — no extra sampling.
- Different suspend trigger: swap the visibility subscription for window
blur/focus, or for an IntersectionObserver, if the panel should only measure
while it is on screen. Keeping the loop alive across a hidden tab is the one
variant to avoid: browsers do not fire rAF there, so the reading would be
fabricated.
- Sharing one loop: for several probes on one page, hoist a module-level loop
plus a subscriber set and let each consumer keep its own window over the same
timestamps; the returned shape does not change.
- Rendering the number without React: for a zero-re-render overlay, ignore
`fps` entirely and write into a DOM node from `onJank` plus your own
`requestAnimationFrame` — the hook's cadence exists for components that
actually want to re-render.Concepts
- Rolling window over rAF timestamps — the rate is not "one over the last delta" but the count of intervals across the timestamps still inside the window, divided by the span they actually cover. Dividing by the retained span instead of the requested window is what lets a reading be correct two frames after starting, and what keeps it honest when the buffer wraps on a very fast display.
- Publish cadence vs sampling rate — sampling happens every frame,
setStatehappens four times a second. A probe that re-rendered its consumer per frame would depress the very number it reports; separating the two is the difference between an instrument and an observer effect. - A hidden tab is a gap, not a zero — browsers stop firing rAF in a background tab, so a probe that keeps measuring across the switch invents a collapse to a fraction of a frame per second. Here the loop is cancelled, the status says
hidden, the last reading is held, and the return re-baselines so the multi-second gap is neither counted as jank nor averaged into the rate. - Jank measured against the display, not against 60 — a frame over the threshold is counted once and described by how many frames it swallowed, where the frame budget is a moving average of the healthy frames rather than a hard-coded 16.7 ms. On a 120 Hz screen that halves the budget and doubles the honesty;
missedFrames: 0is a real answer for a threshold set below the refresh interval. - Clamp the impossible, obey the merely wrong — a zero-length window cannot hold samples, so it is clamped to a floor; a four-millisecond jank threshold is a bad budget, not an impossible one, so it is applied literally and the runaway counter tells the developer what they asked for. Silently repairing hostile input is how a tool starts lying.
- Held readings, a synchronous stop, and no cost while unwatched — stopping freezes the numbers instead of zeroing them, because a panel that blanks the moment you look away is useless;
stop()writes a ref before it sets state, so freezing the panel from inside a jank callback takes effect on that frame rather than a render or two later; and withautoStart: falsethere is no frame callback and no allocation at all until someone opens the panel, which is what makes it acceptable to leave the hook mounted behind a debug flag.
useWorker
Run a pure function off the main thread: the function is stringified into a Blob worker, every call is a promise matched back by id so out-of-order completions land on the right caller, transferables move in both directions, task errors reject without replacing the worker, and everything is terminated on unmount.
useTextToSpeech
The browser speech engine as a hook — speak/pause/resume/cancel, a voice list that survives the async voiceschanged population, word boundary events mapped back into your own string, and a cancel on unmount so navigation never leaves a voice talking.