lite-fps-meter
v1.3.0
Published
Lightweight visual FPS monitor with auto-refresh-rate detection, compact mode, theming, and zero dependencies.
Maintainers
Readme
lite-fps-meter
A lightweight, zero-dependency FPS monitor that renders a real-time graph overlay on a canvas element.
Drop it into any web project during development to spot jank, profile animations, or verify you're hitting your target frame rate. Auto-detects the display refresh rate (60Hz, 120Hz, 144Hz, etc.) and adapts thresholds automatically.
🎬 Live Demo (SmartObserver)
https://codepen.io/Zahari-Shinikchiev/debug/LERWgyQ
Features
- Zero dependencies — single ES module, no build step required
- Auto-detects refresh rate — adapts target from 30Hz to 240Hz displays
- Canvas-rendered graph — scrolling frame-time chart with a budget rule and color-coded verdict
- Low observer cost — the graph repaints at ~10 Hz, not every frame (~6.7x fewer canvas ops at 60 Hz); its own overhead is published below
- Self- or host-driven — runs its own rAF, or measure your loop with
tick(now)(loop: false) - Visibility-aware — auto-pauses a backgrounded tab, so returning to it records no phantom stall
- Compact mode — text-only readout when
graph: false(15px tall) - EMA smoothing — configurable exponential moving average for the headline number
- Themeable — override any color (good/ok/bad/bg/mid/detecting/clip)
- Clean teardown —
destroy()cancels all rAF frames, drops the visibility listener, and removes DOM elements
Installation
npm install lite-fps-meterOr drop the file directly into your project — it's a single ES module.
Quick Start
import { FPSMeter } from 'lite-fps-meter';
// Create and start (auto-attaches to document.body)
const meter = new FPSMeter();
// Later: clean up
meter.destroy();A fixed-position overlay appears in the top-left corner showing current FPS, min/max range, and a scrolling bar graph.
Options
const meter = new FPSMeter({
width: 120, // Canvas width (px)
height: 50, // Canvas height (px) — auto-shrinks to 15 when graph: false
graph: true, // Show scrolling bar graph (false = compact text-only)
graphHeight: 30, // Graph area height (px)
textUpdateInterval: 100, // Min ms between text refreshes
smoothing: 0.1, // EMA factor (0–1). Lower = smoother, higher = responsive
position: 'top-left', // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
target: null, // Mount target element (default: document.body)
targetFps: null, // Pin the budget (skips auto-detect) — e.g. 60, 144
loop: true, // Self-driven rAF. false = host-driven (see below); needs targetFps
theme: { // Color overrides (merged with defaults)
good: '#0f0', // verdict: steady
ok: '#ff0', // verdict: spiking
bad: '#f00', // verdict: throttled
bg: '#111', // Background
mid: '#800', // 50% target line
detecting: '#aaa', // Text during refresh rate detection
},
});Compact Mode
For a minimal footprint, disable the graph. The meter shrinks to a 15px-tall text-only readout:
const meter = new FPSMeter({ graph: false, position: 'bottom-right' });API
| Method | Description |
|--------|-------------|
| new FPSMeter(options?) | Create meter and start measuring immediately (self-driven mode) |
| .tick(now) | Advance one host-driven frame at now (a performance.now() timestamp). See Host-driven mode. Zero allocation; no-op if destroyed/paused; rejects non-finite now. |
| .pause() | Pause the measurement loop |
| .resume() | Resume after pause (rebases the frame clock to avoid a delta spike) |
| .reset() | Clear min/max counters, history graph, dropped count, and refresh text |
| .getStats() | Return a plain stats object (see below). One fresh object per call, zero allocation per frame. |
| .destroy() | Stop everything, drop the visibility listener, remove DOM elements. Idempotent. |
getStats()
Returns a snapshot for programmatic reads — the escape hatch for anyone who wants the numbers without reading the overlay:
const s = meter.getStats();
// { fps, min, max, minMs, maxMs, p50, p99, budgetMs, targetFPS,
// dropped, jankRatio, spikeRatio, frameClass, samples, detecting }dropped— cumulative missed vsyncs since reset, defined assum of max(0, round(delta / budgetMs) - 1). A perfectly paced frame counts 0; a 200 ms frame at 60 Hz counts 11. This exact definition is what makes the number comparable across runs.p50/p99— median and p99 raw frame time in ms, computed on the text tick (never per frame). Window-relative: over at mostwidthretained samples, not the whole session. A 120-sample p99 is a weak statistic — treat it as "the tail of the recent window", not a session-wide claim.frameClass—'steady'/'spiking'/'throttled'. See below.
Properties
| Property | Type | Description |
|----------|------|-------------|
| .fps | number | Current smoothed FPS (EMA) — the headline number only |
| .min | number | Worst-case FPS since last reset (from the largest raw frame time) |
| .max | number | Best-case FPS since last reset (from the smallest raw frame time) |
| .minMs | number | Best raw frame time in ms since last reset |
| .maxMs | number | Worst raw frame time in ms since last reset — the honest jank number |
| .budgetMs | number | Frame budget in ms, from the detected or pinned refresh rate |
| .dropped | number | Cumulative dropped (missed-vsync) frames since last reset |
| .targetFPS | number | Detected display refresh rate (or the targetFps override) |
| .showGraph | boolean | Whether the bar graph is rendered |
| .smoothing | number | EMA smoothing factor |
| .theme | object | Active color theme |
The verdict (color)
The overlay color is driven by a budget-relative verdict so color and verdict
can never contradict. It deliberately mirrors
@zakkster/lite-profiler's
FrameClass names and thresholds so the drop-in overlay and the rigorous
profiler tell the same story.
fps-meter measures frame interval (a healthy 60 Hz frame is ~16.67 ms), not work time, so the jank/spike edges are relative to the budget, not the absolute 16/33 ms lite-profiler uses — otherwise every healthy 60 Hz frame would be flagged as jank. Over the retained window:
jankRatio= fraction of samples withms >= 1.5 × budgetMsspikeRatio= fraction withms >= 2 × budgetMs(reported, not used by the verdict)
| Color | frameClass | Condition (jankRatio only, exactly lite-profiler's classify()) |
|-------|--------------|------------------------------------------------------------------|
| 🟢 Green | steady | jankRatio < 0.05 |
| 🟡 Yellow | spiking | 0.05 <= jankRatio < 0.25 |
| 🔴 Red | throttled | jankRatio >= 0.25 |
The verdict is computed over the current window and recovers when jank stops.
Host-driven mode (whose frames?)
By default the meter runs its own requestAnimationFrame loop, so it measures the
display's frame cadence. Usually that is what you want — but not always. For a
fixed-timestep game, a worker-driven render, or an app that has stopped calling rAF,
the self-driven meter will cheerfully report 60 fps for a frozen application, because
the browser is still firing frames even though your loop is not.
Set loop: false and drive the meter from your own loop instead. It then owns zero
requestAnimationFrame and measures exactly the loop you call it from:
// Host-driven: the meter measures YOUR loop, not the display.
const meter = new FPSMeter({ loop: false, targetFps: 60 });
function frame(now) {
// ... your game / render step ...
meter.tick(now); // one call per frame; same clock as performance.now()
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);Because there is no rAF to auto-detect the refresh rate with, host-driven mode
requires a pinned budget: pass targetFps, or it fails closed to 60 Hz.
Sharing one rAF? There is no dependency to add. If you already run a single loop (your own, or a
@zakkster/lite-rafdriver), just callmeter.tick(now)from it. The bridge is a one-liner, not a package.
Overhead
A performance tool should state its own cost. The graph repaints on the text-update tick (~10 Hz by default), not every frame — the frame-time ring still records every frame, so no history is lost; only the canvas work throttles. Measured at the default 120 px width, 60 Hz, with a full graph:
| | Repaints / s | fillRect / s | Per repaint |
|---|---|---|---|
| Before (every frame) | 60 | 7380 | 123 fillRect + 1 fillText |
| After (F3, ~10 Hz) | ~10 | ~1107 | same per repaint |
That is ~6.7x fewer canvas operations at 60 Hz, and the saving grows with refresh
rate (a 144 Hz display previously drew ~14x as often). The graph lags reality by at most
one textUpdateInterval (100 ms) — imperceptible on a scrolling chart. The per-frame
measurement path (tick / the internal loop) allocates nothing.
How It Works
Refresh rate detection: On creation, the meter counts requestAnimationFrame callbacks over a 250ms window and derives the average frame rate. Background-throttled frames (>100ms) are discarded. The result is clamped to 30–240Hz. Detection survives pause — it resumes timing when the meter restarts.
FPS calculation: Each frame computes an instantaneous FPS from the delta, then applies an exponential moving average: fps = fps + (instant - fps) * smoothing. The EMA drives the headline number only; min/max, percentiles, and the verdict are computed over the raw frame-time ring, never the smoothed series. Zero, negative, and non-finite deltas (tab resume, first frame, a bad timestamp) are rejected before they can touch the clock.
Graph rendering: A Float32Array ring retains the last N raw frame times (not pixel heights). Each bar is drawn proportional to ms against a fixed 2x-budget scale — a 1x-budget frame reaches the budget rule, a 2x frame fills the graph, and a worse frame is clamped and flagged in theme.clip, so bad frames spike upward. The repaint runs on the ~10 Hz text tick, not every frame (see Overhead); the ring is still written every frame. In compact mode the bar loop is skipped entirely.
Page visibility: A backgrounded tab throttles rAF to ~1 Hz, which would otherwise surface as a huge fabricated stall the moment you return. The meter listens for visibilitychange, pauses while hidden, and rebases the frame clock on return so the gap is discarded — a tab round-trip records no dropped frames. The listener is removed in destroy().
TypeScript
Full type definitions included:
import { FPSMeter, type FPSMeterOptions, type FPSMeterTheme } from 'lite-fps-meter';
const meter = new FPSMeter({
position: 'bottom-right',
theme: { good: '#00ff88' },
});License
MIT
