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.
| Entry | What it holds |
|---|---|
framebudget | budget, configure, createBudget, Tier, defaultCalibration and the types. |
framebudget/boot | bootScript and createBootScript(options), the inline script for <head>. |
framebudget/react | useBudget, useTier and BudgetProvider. |
framebudget/panel | mountPanel(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.
| Member | Description |
|---|---|
budget.allows(effect) | May this effect run on this device? Unknown effects answer false. |
budget.tier | The current tier: "Full", "High", "Medium" or "Lite". |
budget.score | The 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
| Reason | When |
|---|---|
warm | The warm benchmark at load changed the decision. |
governor | The governor stepped an effect down. |
pressure | Compute Pressure changed state. |
motion | The reduced-motion, Save-Data or connection preference changed. |
force | force() was called. |
simulate | simulate() was called. Always emitted, even when nothing changed. |
configure | configure() 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:
| Effect | Threshold | Cost | Flags |
|---|---|---|---|
hover | 20 | 1 | none |
canvasLowRes | 30 | 3 | motion |
entrances | 35 | 2 | motion |
shimmer | 45 | 2 | motion |
sound | 50 | 1 | data |
pageTransition | 55 | 3 | motion |
parallax | 70 | 5 | motion |
blur | 90 | 6 | none |
canvasHiRes | 120 | 8 | motion, 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.
| Tier | Floor | Built-in effects |
|---|---|---|
| Lite | 20 | hover |
| Medium | 35 | adds canvasLowRes, entrances |
| High | 70 | adds shimmer, sound, pageTransition, parallax |
| Full | 120 | adds 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.sendBeaconcall 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
Originheader.
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
| Tool | What it does |
|---|---|
?framebudget | Opens 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=High | Forces a tier for the session. auto goes back. |
?framebudget-score=40 | Simulates a score for the session. off goes back. |
data-framebudget | The tier, on <html>. |
data-framebudget-effects | The allowed effects, space separated, on <html>. |