API reference

One object, budget, answers whether an effect may run. Everything else feeds it better numbers or reports what it saw.

This page, right now

<html data-framebudget="...">

Install

npm i framebudget

ESM only, with type declarations. react is an optional peer dependency, needed only for framebudget/react.

EntryWhat it holds
framebudgetbudget, configure, createBudget, Tier, defaultCalibration and the types.
framebudget/bootbootScript and createBootScript(options), the inline script for <head>.
framebudget/reactuseBudget, useTier and BudgetProvider.
framebudget/panelmountPanel(budget), the diagnostics overlay.

Boot script

Inline it as the first script in <head>, before your CSS. It runs the cold benchmark (about 1.6 ms of work), decides, and writes the result on <html> before the first paint. It is self-contained (3.7 KB gzipped), never throws, and works when any browser API is missing.

import { bootScript, createBootScript } from "framebudget/boot";

// Default options:
const head = `<script>${bootScript}</script>`;

// With the calibration you pass to configure(), so boot and core agree:
const custom = createBootScript({ calibration: { effects: { confetti: { threshold: 60, cost: 4, motion: true } } } });

CSS can then gate effects without any JavaScript:

html[data-framebudget-effects~="parallax"] .hero { transform: translateY(var(--parallax)); }
html[data-framebudget-effects~="blur"] .sheet { backdrop-filter: blur(16px); }

With a Content Security Policy, add a nonce or the script's hash.

The budget

The core starts on the first call to any method. It adopts the boot script's decision, runs a longer warm benchmark at the load event, then starts the governor. On the server, allows() answers false and the tier is Lite.

MemberDescription
budget.allows(effect)May this effect run on this device? Unknown effects answer false.
budget.tierThe current tier: "Full", "High", "Medium" or "Lite".
budget.scoreThe score after hardware caps and pressure, or null on the server.
budget.effects()The allowed effects.
budget.snapshot()Everything: scores, kernel rates, hints, the reason each effect is off, fps per source, the calibration.
budget.on("change", fn)Calls fn(snapshot, reason) and returns the unsubscribe function.
budget.off("change", fn)Unsubscribes.
budget.reportFrame(gapMs, source?)Reports the time between two frames, for the governor.
budget.register(name, definition)Adds an effect, or replaces one.
budget.configure(options)Calibration, governor and sharing options. Call it before the load event. Also exported as configure.
budget.force(tier | "auto")Forces a tier for the session.
budget.simulate(score | null)Debug and demo only: replaces the measured score for the session.
createBudget(options?)A separate instance, for tests or embedded widgets.
import { budget } from "framebudget";

if (budget.allows("parallax")) startParallax();

budget.on("change", (snapshot, reason) => {
  update(snapshot.effects);
});

Change reasons

ReasonWhen
warmThe warm benchmark at load changed the decision.
governorThe governor stepped an effect down.
pressureCompute Pressure changed state.
motionThe reduced-motion, Save-Data or connection preference changed.
forceforce() was called.
simulatesimulate() was called. Always emitted, even when nothing changed.
configureconfigure() or register() changed the decision.

Effects

Each effect has a score threshold, a relative frame cost, and optional flags. motion effects turn off under prefers-reduced-motion: reduce; data effects turn off under Save-Data or a 2g connection. These are the library's built-in effects:

EffectThresholdCostFlags
hover201none
canvasLowRes303motion
entrances352motion
shimmer452motion
sound501data
pageTransition553motion
parallax705motion
blur906none
canvasHiRes1208motion, data

Register your own, and pass the same definitions to createBootScript so the first paint already knows them:

budget.register("confetti", { threshold: 60, cost: 4, motion: true });

Hysteresis. An effect that was on stays on down to threshold * (1 - hysteresis), and an effect that was off turns on only from threshold * (1 + hysteresis). The default margin is 10%, and the previous state is kept across pages.

Tiers

A tier's set is every effect whose threshold is at or below the tier floor. The reported tier is the highest tier whose whole set is allowed. Effects off because of a user preference do not lower the tier; thresholds, learning and the governor do.

TierFloorBuilt-in effects
Lite20hover
Medium35adds canvasLowRes, entrances
High70adds shimmer, sound, pageTransition, parallax
Full120adds blur, canvasHiRes

Simulating a device

budget.simulate(score) replaces the measured score for the session, and budget.simulate(null) returns to the real device. The boot script and the core both read the session value, so the next page's first paint already uses it. Try it: the device you picked on the home page is still active here, and the dock at the bottom takes you back.

The simulated score goes through the normal path: thresholds with hysteresis, hardware caps, pressure, reduced motion and the governor. While simulating, nothing is written about the real device and no telemetry is sent. snapshot().simulated holds the score. This API is for debugging and demos, not for product decisions.

budget.simulate(35);   // a budget phone from 2019
budget.simulate(null); // back to the real device

Governor

The governor starts at load, ignores the first 3 s, then judges windows of 30 frames by their median gap. A window under the target (45 fps) is a strike. After 3 strikes in a row from one source it steps one effect down, waits a 2 s cooldown, and emits change.

  • Frames reported as "main" or "worker" step down the most expensive allowed effect.
  • Frames reported with an effect's name step down that effect: report a canvas loop as budget.reportFrame(gap, "canvasHiRes").
  • framebudget samples main-thread frames itself during the first seconds and while the visitor interacts. Turn that off with configure({ governor: { auto: false } }).
// worker
postMessage({ type: "frame", gap });
// page
worker.addEventListener("message", (e) => {
  if (e.data.type === "frame") budget.reportFrame(e.data.gap, "worker");
});

Tune targetFps, windowFrames, strikes, warmupMs, cooldownMs, cleanWindows and maxGapMs with configure({ governor }).

Local learning

Always on, and it stays on the device: one guarded localStorage entry per origin. When the governor steps an effect down, the next visits start without it. After 5 clean visits it is tried again; if it holds up it is forgotten, and if it stutters again the wait doubles. The cached warm score and the last qualified effects live in the same entry.

Calibration

Every number that turns measurements into decisions lives in defaultCalibration: reference rates, effect thresholds and costs, tier floors, hysteresis, hardware caps, pressure factors, the fallback score, the maximum age of a cached score and the clean visits before a retry. Precedence: built-in numbers, then a calibration fetched from your server, then your overrides. Every value is validated.

configure({
  calibration: {
    effects: { parallax: { threshold: 80 } },
    tiers: { High: 80 },
    hysteresis: 0.15,
  },
});

Telemetry

Off by default, with no default endpoint. Only the site developer can turn it on:

configure({
  share: {
    endpoint: "https://example.com/framebudget",
    sampleRate: 0.1, // share of page views that report
    calibrationUrl: "https://example.com/framebudget/calibration.json",
  },
});
  • Sent: calibration version, rounded scores, kernel rates, clock resolution, cores, memory, pressure, the reduced-motion preference, the tier, allowed and stepped-down effects, median fps per source.
  • Never sent: identifiers, cookies, the URL, the user agent, timestamps.
  • When: one navigator.sendBeacon call when the page is hidden, after load, for sampled page views only.
  • Never under Global Privacy Control or Save-Data, while a tier is forced, or while a device is simulated.
  • The beacon still carries what every request carries, such as the IP address and the Origin header.

React

import { useBudget, useTier } from "framebudget/react";

function Page() {
  const animate = useBudget("pageTransition"); // boolean, re-renders on change
  const tier = useTier();
  return animate ? <AnimatedRoute /> : <Route />;
}

During server rendering and hydration, useBudget answers false and useTier answers Lite, so effects only start on the client. BudgetProvider is only needed for a budget other than the page's.

Debugging

ToolWhat it does
?framebudgetOpens the diagnostics panel: scores, kernel rates, hints, each effect's state, fps per source, buttons to force a tier. Loaded on demand from framebudget/panel.
?framebudget-tier=HighForces a tier for the session. auto goes back.
?framebudget-score=40Simulates a score for the session. off goes back.
data-framebudgetThe tier, on <html>.
data-framebudget-effectsThe allowed effects, space separated, on <html>.

Open the simulator