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 history bar chart with color-coded thresholds
- Compact mode — text-only readout when
graph: false(15px tall) - EMA smoothing — configurable exponential moving average for stable readouts
- Themeable — override any color (good/ok/bad/bg/mid/detecting)
- Configurable — position, dimensions, smoothing factor, mount target
- Clean teardown —
destroy()cancels all rAF frames and removes DOM elements
Installation
npm install lite-fps-meter
Or 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
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 |
.pause() |
Pause the measurement loop |
.resume() |
Resume after pause (resets timestamp to avoid 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, 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) |
.min |
number |
Minimum FPS since last reset |
.max |
number |
Maximum FPS since last reset |
.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.
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. Zero and negative deltas (tab resume, first frame) are skipped to prevent Infinity from polluting the average.
Graph rendering: A Uint8Array ring buffer stores the last N frame heights. On each draw, the buffer is read from the current index (oldest) to produce a left-to-right scrolling chart. Two reference lines mark 100% and 50% of the target FPS. In compact mode, the buffer is never written and the bar loop is skipped entirely.
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