npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

lite-fps-meter

v1.3.0

Published

Lightweight visual FPS monitor with auto-refresh-rate detection, compact mode, theming, and zero dependencies.

Readme

lite-fps-meter

npm version sponsor npm bundle size npm downloads npm total downloads TypeScript Zero Dependencies License: MIT

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-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
    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 as sum 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 most width retained 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 with ms >= 1.5 × budgetMs
  • spikeRatio = fraction with ms >= 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-raf driver), just call meter.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