Hooks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

/**
 * What the probe is doing right now. Three states, and the third one is the
 * reason this is a state at all rather than a boolean:
 *
 * - `idle` — nobody is watching. No frame is scheduled, no sample buffer is
 *   allocated, and the readings hold whatever the last run measured.
 * - `measuring` — a `requestAnimationFrame` loop is running and the readings
 *   are live.
 * - `hidden` — the tab went to the background. Browsers stop firing
 *   `requestAnimationFrame` there, so the honest answer is "unknown", not

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-fps.json

Prompt

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, setState happens 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: 0 is 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 with autoStart: false there 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.

On This Page