PerfMonitor
Show frame-gap statistics, a histogram against your frame budget, and each store's lane health. Add application readouts and send reports to a log or test harness.
import { PerfMonitor } from "@/components/ui/perf-monitor"
export default function PerfMonitorDemo() {
return <PerfMonitor className="w-80 max-w-full" />
}
Installation
pnpm dlx shadcn@latest add tradecn/ui/perf-monitor#v1.4.13
npx shadcn@latest add tradecn/ui/perf-monitor#v1.4.13
yarn dlx shadcn@latest add tradecn/ui/perf-monitor#v1.4.13
bunx --bun shadcn@latest add tradecn/ui/perf-monitor#v1.4.13
-
Install the dependencies:
pnpm add cnnpm install cnyarn add cnbun add cn -
Copy the files into your project:
components/ui/ perf-monitor.tsx import { cn } from "cn" import { useEffect, useState, useSyncExternalStore } from "react" import { createFrameSampler, formatMs, type FrameReport, type FrameSampler } from "@/lib/frame-stats" // The frame rate, on the screen it measures. A strip of numbers a per-frame core is judged by (frames // in the window, p50, p99, max, dropped, long tasks), a histogram of frame gaps against the budget, // and one line per lane from its store's meta: rows, batches a second, drops, sequence, gap, age. // // It costs almost nothing to watch. The sampler pushes one number into a ring per animation frame // and reports on a timer, so this component re-renders four times a second and never per frame, and // the lanes read their store's meta on the same beat. It decides nothing: the numbers are the numbers, // read on the machine that matters, and pass or fail is written down somewhere else, beforehand. /** What a lane shows, the shape a row store's meta already has. */ export interface LaneMeta { lane: "coalesced" | "ordered" size: number /** Counts batches applied. */ version: number dropped: number seq: number | null gap: boolean /** Wall clock of the last batch, ms since the epoch. */ lastBatchAt: number | null } /** What a lane reads: a row store, or anything with a meta subscription shaped like one. */ export interface MetaSource { getMeta(): LaneMeta subscribeMeta(cb: () => void): () => void } export interface PerfMonitorLane { label: string store: MetaSource } export interface PerfReadout { label: string /** Printed as given: an IPC batch size, a queue depth, a socket's state. */ value: string } export interface PerfMonitorProps { /** Stores whose lanes to show. */ lanes?: readonly PerfMonitorLane[] /** The frame budget the histogram marks. Default 1000/60. */ budgetMs?: number /** Frames kept. Default 600, ten seconds at 60 Hz. */ window?: number /** How often the strip redraws. Default 250. */ refreshMs?: number /** Your own sampler, for a report you read elsewhere or write to a log. Made here otherwise. Started while mounted, stopped after. */ sampler?: FrameSampler /** Numbers of your own beside the frame numbers. */ readouts?: readonly PerfReadout[] /** Each report as it lands, for a log. */ onReport?: (report: FrameReport) => void /** The numbers alone, no histogram. */ compact?: boolean label?: string className?: string } const noop = () => () => {} function useMeta(source: MetaSource): LaneMeta { return useSyncExternalStore(source.subscribeMeta, source.getMeta, source.getMeta) } interface LaneProps { lane: PerfMonitorLane /** The report's clock: when the strip last redrew. */ at: number } function Lane({ lane, at }: LaneProps) { const meta = useMeta(lane.store) // Batches a second between two redraws: derived state, settled during render. const [seen, setSeen] = useState({ at, version: meta.version, rate: 0 }) if (seen.at !== at) { const seconds = (at - seen.at) / 1000 setSeen({ at, version: meta.version, rate: seconds > 0 ? Math.max(0, meta.version - seen.version) / seconds : 0 }) } const age = meta.lastBatchAt === null ? null : Math.max(0, at - meta.lastBatchAt) return ( <div className="flex flex-wrap items-baseline gap-x-2 lining-nums tabular-nums" data-perf-lane={lane.label} data-lane={meta.lane} data-numeric=""> <span className="font-medium">{lane.label}</span> <span className="text-muted-foreground">{meta.lane}</span> <span> <span className="text-muted-foreground">rows </span> {meta.size.toLocaleString()} </span> <span> <span className="text-muted-foreground">batches/s </span> {seen.rate.toFixed(0)} </span> {meta.lane === "coalesced" && ( <span data-perf-dropped> <span className="text-muted-foreground">drop </span> {meta.dropped.toLocaleString()} </span> )} {meta.lane === "ordered" && ( <span data-perf-seq> <span className="text-muted-foreground">seq </span> {meta.seq === null ? "–" : meta.seq.toLocaleString()} {meta.gap && <span className="text-stale"> gap</span>} </span> )} <span> <span className="text-muted-foreground">age </span> {age === null ? "–" : formatMs(age)} </span> </div> ) } interface HistogramProps { report: FrameReport budgetMs: number } // One series, one hue: how the frame gaps fell against the budget. The budget is a labeled line, the // numbers beside the chart are its table. function Histogram({ report, budgetMs }: HistogramProps) { const bins = report.histogram const width = 160 const height = 28 const gap = 1 const barWidth = width / bins.length const peak = Math.max(1, ...bins) const budgetX = Math.min(width, (budgetMs / report.binMs) * barWidth) const end = report.binMs * (bins.length - 1) return ( <svg role="img" aria-label={`Frame time histogram, ${report.binMs} ms bins to ${end} ms and over: p50 ${formatMs(report.p50)}, p99 ${formatMs(report.p99)}, ${report.dropped} dropped`} viewBox={`0 0 ${width} ${height + 10}`} width={width} height={height + 10} className="shrink-0 overflow-visible" data-perf-histogram> {bins.map((count, i) => { const h = count === 0 ? 0 : Math.max(1, Math.round((count / peak) * height)) const from = i * report.binMs const label = i === bins.length - 1 ? `${from} ms and over: ${count} frames` : `${from} to ${from + report.binMs} ms: ${count} frames` return ( <rect key={i} x={i * barWidth + gap / 2} y={height - h} width={Math.max(0.5, barWidth - gap)} height={h} rx={1} className="fill-primary" data-bin={i} data-count={count}> <title>{label}</title> </rect> ) })} <line x1={budgetX} x2={budgetX} y1={0} y2={height} strokeDasharray="2 2" className="stroke-muted-foreground" strokeWidth={1} /> <text x={Math.min(budgetX + 2, width - 34)} y={height + 9} className="fill-muted-foreground" fontSize={7} data-perf-budget> {formatMs(budgetMs)} </text> </svg> ) } export function PerfMonitor({ lanes, budgetMs = 1000 / 60, window: frames = 600, refreshMs = 250, sampler: given, readouts, onReport, compact = false, label = "Frame health", className }: PerfMonitorProps) { const [own] = useState(() => given ?? createFrameSampler({ budgetMs, window: frames, refreshMs })) const sampler = given ?? own useEffect(() => { sampler.start() return () => sampler.stop() }, [sampler]) const report = useSyncExternalStore(sampler.subscribe ?? noop, sampler.report, sampler.report) useEffect(() => { onReport?.(report) // The report is what changed; the callback is read as it is then. // eslint-disable-next-line react-hooks/exhaustive-deps }, [report]) const readout = (key: string, name: string, value: string) => ( <span data-perf={key}> <span className="text-muted-foreground">{name} </span> {value} </span> ) return ( <div role="group" aria-label={label} data-slot="tradecn-perf-monitor" data-dropped={report.dropped} data-frames={report.frames} className={cn("flex flex-col gap-1 font-(family-name:--tradecn-font-mono) text-xs lining-nums tabular-nums", className)}> <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> {!compact && <Histogram report={report} budgetMs={budgetMs} />} <div className="flex flex-wrap items-baseline gap-x-2 lining-nums tabular-nums" data-numeric=""> {readout("frames", "frames", report.frames.toLocaleString())} {readout("p50", "p50", formatMs(report.p50))} {readout("p99", "p99", formatMs(report.p99))} {readout("max", "max", formatMs(report.max))} {readout("dropped", "dropped", String(report.dropped))} {readout("long", "long", report.longTasksObserved ? String(report.longTasks) : "n/a")} {readouts?.map((r) => ( <span key={r.label} data-perf-readout={r.label}> <span className="text-muted-foreground">{r.label} </span> {r.value} </span> ))} </div> </div> {lanes?.map((lane) => <Lane key={lane.label} lane={lane} at={report.until} />)} </div> ) }lib/frame-stats.ts // Frame timing, measured the way a per-frame core is judged: the gap between one animation frame and // the next, how many frames missed their slot, how long the longest tasks ran. Pure functions over // the numbers, and a sampler that collects them from requestAnimationFrame and a PerformanceObserver // into a bounded ring, reporting on a timer rather than on every frame, so watching the frame rate // costs almost none of it. export interface FrameReport { /** Frames in the window. */ frames: number p50: number p99: number max: number mean: number /** Frames longer than `droppedAboveMs`. */ dropped: number droppedAboveMs: number /** Long tasks (over 50 ms) seen while sampling, where the browser reports them. */ longTasks: number /** Whether long tasks could be observed at all. */ longTasksObserved: boolean /** Counts per bin, `binMs` wide from zero; the last bin holds everything past the end. */ histogram: number[] binMs: number /** When the window started and ended, ms since the epoch. */ since: number until: number } export interface FrameSamplerOptions { /** Frames kept. Default 600, ten seconds at 60 Hz. */ window?: number /** The frame budget. Default 1000/60. A frame past 1.5 times it counts as dropped. */ budgetMs?: number /** Histogram bin width. Default 2. */ binMs?: number /** Histogram end; the last bin takes everything past it. Default 40. */ maxMs?: number /** How often subscribers hear a new report. Default 250. */ refreshMs?: number /** Injectable for tests. */ raf?: (cb: (t: number) => void) => number caf?: (id: number) => void now?: () => number setTimer?: (cb: () => void, ms: number) => unknown clearTimer?: (id: unknown) => void observe?: boolean } export interface FrameSampler { start(): void stop(): void /** Forget every frame so far. */ reset(): void /** The latest report; the same object until the next refresh. */ report(): FrameReport /** Wakes on each refresh while running. */ subscribe(cb: () => void): () => void readonly running: boolean } export function percentile(sorted: readonly number[], p: number): number { if (!sorted.length) return 0 const i = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1)) return sorted[i]! } /** Counts per `binMs`-wide bin from zero to `maxMs`, the last bin taking everything past the end. */ export function histogram(values: readonly number[], binMs: number, maxMs: number): number[] { const bins = Math.max(1, Math.ceil(maxMs / binMs)) const counts = new Array<number>(bins).fill(0) for (const v of values) { if (!Number.isFinite(v) || v < 0) continue counts[Math.min(bins - 1, Math.floor(v / binMs))]!++ } return counts } export interface SummarizeOptions { budgetMs: number binMs: number maxMs: number longTasks: number longTasksObserved: boolean since: number until: number } /** A report from frame gaps. Dropped is a gap past 1.5 times the budget: the frame missed its slot. */ export function summarize(gaps: readonly number[], o: SummarizeOptions): FrameReport { const sorted = [...gaps].sort((a, b) => a - b) const droppedAboveMs = o.budgetMs * 1.5 return { frames: sorted.length, p50: percentile(sorted, 50), p99: percentile(sorted, 99), max: sorted[sorted.length - 1] ?? 0, mean: sorted.length ? sorted.reduce((a, b) => a + b, 0) / sorted.length : 0, dropped: sorted.filter((d) => d > droppedAboveMs).length, droppedAboveMs, longTasks: o.longTasks, longTasksObserved: o.longTasksObserved, histogram: histogram(sorted, o.binMs, o.maxMs), binMs: o.binMs, since: o.since, until: o.until, } } /** "16.7 ms", "3.2 ms". */ export function formatMs(ms: number): string { return `${ms.toFixed(1)} ms` } export function createFrameSampler(options: FrameSamplerOptions = {}): FrameSampler { const size = options.window ?? 600 const budgetMs = options.budgetMs ?? 1000 / 60 const binMs = options.binMs ?? 2 const maxMs = options.maxMs ?? 40 const refreshMs = options.refreshMs ?? 250 const raf = options.raf ?? ((cb) => requestAnimationFrame(cb)) const caf = options.caf ?? ((id) => cancelAnimationFrame(id)) const now = options.now ?? (() => Date.now()) const setTimer = options.setTimer ?? ((cb, ms) => setInterval(cb, ms)) const clearTimer = options.clearTimer ?? ((id) => clearInterval(id as ReturnType<typeof setInterval>)) const observe = options.observe ?? true // A ring of the last `size` gaps; `head` is the next slot, `count` how many are filled. const ring = new Float64Array(size) let head = 0 let count = 0 let last = -1 let frame = 0 let timer: unknown = null let observer: PerformanceObserver | null = null let longTasks = 0 let observed = false let since = now() let running = false const listeners = new Set<() => void>() let current: FrameReport = summarize([], { budgetMs, binMs, maxMs, longTasks: 0, longTasksObserved: false, since, until: since }) function gaps(): number[] { const out: number[] = new Array(count) const start = (head - count + size) % size for (let i = 0; i < count; i++) out[i] = ring[(start + i) % size]! return out } function refresh() { current = summarize(gaps(), { budgetMs, binMs, maxMs, longTasks, longTasksObserved: observed, since, until: now() }) for (const cb of listeners) cb() } const tick = (t: number) => { if (last >= 0) { ring[head] = t - last head = (head + 1) % size if (count < size) count++ } last = t frame = raf(tick) } return { get running() { return running }, start() { if (running) return running = true last = -1 since = now() frame = raf(tick) timer = setTimer(refresh, refreshMs) if (observe && typeof PerformanceObserver !== "undefined") { try { observer = new PerformanceObserver((list) => { longTasks += list.getEntries().length }) observer.observe({ type: "longtask", buffered: false }) observed = true } catch { observer = null observed = false } } }, stop() { if (!running) return running = false caf(frame) if (timer !== null) clearTimer(timer) timer = null observer?.disconnect() observer = null refresh() }, reset() { head = 0 count = 0 last = -1 longTasks = 0 since = now() refresh() }, report: () => current, subscribe(cb) { listeners.add(cb) return () => listeners.delete(cb) }, } } -
Add the tokens to your stylesheet:
:root { --stale: oklch(0.52 0.13 75); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .dark { --stale: oklch(0.8 0.15 80); --tradecn-font-accessible: 'Atkinson Hyperlegible Next', 'Atkinson Hyperlegible', ui-sans-serif, system-ui, sans-serif; --tradecn-font-accessible-mono: 'Atkinson Hyperlegible Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --tradecn-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } -
Append this to your stylesheet:
:root[data-accessibility="hyperlegible"], [data-accessibility="hyperlegible"] { --tradecn-font-sans: var(--tradecn-font-accessible); --tradecn-font-mono: var(--tradecn-font-accessible-mono); }
Usage
import { PerfMonitor } from "@/components/ui/perf-monitor"
function FrameHealth() {
return <PerfMonitor className="w-80 max-w-full" />
}
Mount the monitor in the view you want to measure. It starts sampling after mount and reports four times a second, so an initial empty reading fills in as frames arrive. The histogram and numbers show measurements from your browser. The width here lets the readouts wrap on narrow screens.
The monitor stops its sampler on unmount. It needs no row store, feed or load generator for ordinary frame measurements.
Measuring a grid
Install data-grid separately for this composition; it also supplies row-store. The example keeps 300 rows and applies one batch per animation frame when Patches per frame is above zero. Each patch updates a row's bid and ask; batches above 300 visit rows more than once. The fixture is deterministic and stays the same size.
The load starts paused. Increase it to compare measurements, then return to zero to stop updates while the monitor keeps sampling. The Quotes lane shows the store's row count, batch rate, drops and age; patches/frame is an application readout. No producer drops are simulated. Reset measurements clears the supplied sampler's retained gaps and long-task count without changing the load or grid.
The histogram's dashed line marks the frame budget. Measurements depend on the browser and machine; increasing this workload does not promise a particular p99 or dropped-frame count. Use the shared preview alignment control to keep the grid's left edge fixed while resizing columns.
import { useEffect, useState } from "react"
import { createFrameSampler } from "@/lib/frame-stats"
import { createRowStore } from "@/lib/row-store"
import { DataGrid, type ColumnDef } from "@/components/ui/data-grid"
import { PerfMonitor } from "@/components/ui/perf-monitor"
type Row = { id: string; bid: number; ask: number; size: number }
const ROWS = 300
const columns: ColumnDef<Row>[] = [
{ key: "id", header: "Id", width: 80, accessor: (row) => row.id },
{ key: "bid", header: "Bid", width: 80, numeric: true, accessor: (row) => row.bid, format: (value) => Number(value).toFixed(3) },
{ key: "ask", header: "Ask", width: 80, numeric: true, accessor: (row) => row.ask, format: (value) => Number(value).toFixed(3) },
{ key: "size", header: "Size", width: 80, numeric: true, accessor: (row) => row.size },
]
export default function PerfMonitorLoadDemo() {
const [store] = useState(() => {
const rows = createRowStore<Row>({ getRowId: (row) => row.id, lane: "coalesced" })
rows.applyDeltas({ upsert: Array.from({ length: ROWS }, (_, i) => ({ id: `R${i}`, bid: 100, ask: 100.01, size: i + 1 })) })
return rows
})
const [sampler] = useState(() => createFrameSampler())
const [patches, setPatches] = useState(0)
useEffect(() => {
if (patches === 0) return
let frame = 0
let step = 0
const tick = () => {
step++
store.applyDeltas({ patch: Array.from({ length: patches }, (_, i) => {
const bid = 100 + ((step + i) % 1000) / 1000
return { id: `R${i % ROWS}`, fields: { bid, ask: bid + 0.01 } }
}) })
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [store, patches])
return (
<>
<div data-demo-controls className="flex flex-wrap items-center gap-3 text-xs lining-nums tabular-nums">
<label className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
Patches per frame
<input type="range" min={0} max={3000} step={100} value={patches} onChange={(event) => setPatches(Number(event.target.value))} className="w-32 max-w-full" />
<span aria-hidden>{patches}</span>
</label>
<button type="button" className="rounded border border-border px-2 py-1" onClick={() => sampler.reset()}>Reset measurements</button>
</div>
<div className="w-fit max-w-full space-y-2">
<PerfMonitor sampler={sampler} lanes={[{ label: "Quotes", store }]} readouts={[{ label: "patches/frame", value: String(patches) }]} className="w-80 max-w-full" />
<div className="h-48 w-fit max-w-full">
<DataGrid store={store} columns={columns} preset="watchlist" label="Measured quotes" announceRowCount="off" />
</div>
</div>
</>
)
}
API Reference
Props
PerfMonitorProps accepts these optional inputs:
| Prop | Type | Default | Purpose |
|---|---|---|---|
lanes |
readonly PerfMonitorLane[] |
Omitted | Labeled stores to display below the frame statistics. |
budgetMs |
number |
1000 / 60 |
Frame budget in milliseconds; positions the histogram marker and configures an internally created sampler. |
window |
number |
600 |
Number of recent frame gaps retained by an internally created sampler. |
refreshMs |
number |
250 |
Report interval in milliseconds for an internally created sampler. |
sampler |
FrameSampler |
Created internally | Sampler to start, subscribe to, and stop. |
readouts |
readonly PerfReadout[] |
Omitted | Extra values beside the frame statistics; each has a label: string and value: string, printed as given. |
onReport |
(report: FrameReport) => void |
Omitted | Receives the current report after mount and when the report object changes. |
compact |
boolean |
false |
Hides the histogram; keeps frame statistics, readouts, and lanes. |
label |
string |
"Frame health" |
Accessible name of the outer group. |
className |
string |
Omitted | Classes on the outer group. |
What it measures
A frame gap is the difference between consecutive requestAnimationFrame timestamps. The strip shows the number of gaps retained, p50, p99, maximum, dropped count, and observed long-task count. Gaps are measured in milliseconds; the default budget displays as 16.7 ms.
dropped counts gaps strictly greater than 1.5 times the sampler's budget: over 25 ms by default. It counts qualifying gaps, not the number of missed display refreshes. A gap over budget but at or below this threshold does not count as dropped.
The sampler retains at most window gaps, about ten seconds at 60 Hz by default. Its first animation frame establishes a timestamp without adding a gap. Long tasks accumulate across sampling until reset(); they do not roll out with the frame window. The strip shows n/a when long-task observation is unavailable or disabled.
The histogram
The chart uses one hue and a dashed, labeled budget marker. The default histogram has 20 bins: [0, 2), [2, 4), through [36, 38), then 38 ms and over. maxMs: 40 determines the bin count, so the final bin starts at 38 ms and includes all larger gaps.
Each bar's hover title names its range and count. The chart's accessible name includes the bin width, overflow boundary, p50, p99, and dropped count; the same summary statistics appear beside it. A supplied sampler determines the bins, while the monitor's budgetMs sets the marker, clamped to the chart's right edge. Keep that prop aligned with the sampler's budget.
The lanes
Each PerfMonitorLane has a label: string and store: MetaSource. A row-store meets the contract. Use distinct lane labels and distinct readout labels: each list uses its labels as React keys.
MetaSource member |
Type | Contract |
|---|---|---|
getMeta |
() => LaneMeta |
Returns a stable snapshot until metadata changes. |
subscribeMeta |
(cb: () => void) => () => void |
Notifies subscribers when metadata changes; returns an unsubscribe function. |
All LaneMeta fields are required:
| Field | Type | Display |
|---|---|---|
lane |
"coalesced" | "ordered" |
Lane kind beside its label. |
size |
number |
Current row count. |
version |
number |
Counter used to calculate batches per second. |
dropped |
number |
Producer's dropped-message count, shown only for coalesced lanes. |
seq |
number | null |
Latest sequence, shown only for ordered lanes; null displays as –. |
gap |
boolean |
Adds gap beside an ordered lane's sequence when true. |
lastBatchAt |
number | null |
Last batch's wall-clock timestamp in milliseconds since the epoch; null displays age as –. |
Lanes subscribe directly to their stores and can redraw between frame reports. Batches per second starts at zero, then uses the nonnegative change in version divided by elapsed report time, rounded to a whole number for display. It updates when report.until changes; if that timestamp moves backward, the rate resets to zero. A row store increments version on both applyDeltas() and clear(), so a clear also counts toward that rate.
Age is max(0, report.until - lastBatchAt) in milliseconds, using the report's timestamp even when fresh metadata arrives between reports. Lane drops describe producer messages; the frame strip's dropped count describes frame gaps. readouts can show your own IPC batch size, queue depth, or socket state.
What it costs
After the first frame following a start or reset, each animation frame writes one gap to a bounded typed-array ring. The report timer copies and sorts the retained gaps to calculate statistics and notifies subscribers, four times a second by default. Frame ticks do not themselves trigger renders; store notifications and parent updates can still cause renders between reports.
The report
Import createFrameSampler and the FrameReport and FrameSampler types from @/lib/frame-stats. Pass a sampler to the monitor when a test harness, replay gate, or screenshot script also needs report().
FrameReport field |
Type | Meaning |
|---|---|---|
frames |
number |
Number of retained gaps. |
p50, p99 |
number |
Nearest-rank percentiles of retained gaps, in milliseconds; zero when empty. |
max, mean |
number |
Maximum and arithmetic mean gap in milliseconds; zero when empty. The strip omits mean. |
dropped |
number |
Retained gaps strictly greater than droppedAboveMs. |
droppedAboveMs |
number |
Sampler budget multiplied by 1.5. |
longTasks |
number |
Number of browser long-task entries observed since construction or reset, retained across stop/start. |
longTasksObserved |
boolean |
Whether long-task observation was established. Initially false. |
histogram |
number[] |
Gap counts in bins from zero; the final bin includes all overflow. |
binMs |
number |
Width of each ordinary bin in milliseconds. |
since |
number |
Sampler's latest start or reset time, initially its construction time, in milliseconds since the epoch. |
until |
number |
Report refresh time in milliseconds since the epoch, initially its construction time. |
since is not the timestamp of the oldest retained gap and does not advance when the ring fills. The report contains frame statistics only; lane metadata and custom readouts are separate.
createFrameSampler(options) accepts window, budgetMs, and refreshMs with the same defaults as the monitor. It also accepts binMs (default 2), maxMs (default 40), and observe (default true; set false to disable long-task observation). Tests can inject raf, caf, now, setTimer, and clearTimer.
FrameSampler member |
Behavior |
|---|---|
start() |
Starts animation frames, the report timer, and optional long-task observation. Does nothing if already running. |
stop() |
Cancels sampling, disconnects observation, and publishes a final report. Does nothing if already stopped. |
reset() |
Clears gaps and long-task count, resets since, and publishes a report without changing whether sampling is running. |
report() |
Returns the cached FrameReport; it does not take a fresh sample. |
subscribe(cb) |
Notifies on reports from the timer, reset(), or an active stop(); returns an unsubscribe function. |
running |
Readonly boolean indicating whether sampling is running. |
Stopping and restarting retains gaps and the long-task count. Restarting resets since and the previous animation-frame timestamp, so the pause does not become a gap. Call reset() for a fresh measurement. report() returns the same object until the next report is published; start() alone does not publish one.
The monitor starts its selected sampler after mount, stops it on unmount, and stops the old sampler before starting a replacement. This applies to supplied samplers too, so avoid sharing one across monitors with independent lifetimes. The initial sampler is retained as the fallback if sampler is later omitted.
An internally created sampler captures window, budgetMs, and refreshMs on the first render. Changing those props does not reconfigure it; remount or provide a new sampler. A supplied sampler owns its configuration. In either case, changing the monitor's budgetMs still moves the chart marker.
onReport runs after mount with the current snapshot, which can be the initial empty report, and after report-object changes. Replacing the callback alone does not invoke it. React development effect replay can repeat the initial callback.
percentile(sorted, p), histogram(values, binMs, maxMs), and summarize(gaps, options) expose the arithmetic as pure functions. percentile expects sorted values and returns zero for an empty array; summarize sorts its own copy. formatMs(ms) formats one decimal place with an ms suffix.
What it does not do
The monitor shows measurements without deciding pass or fail. Set thresholds before a run and measure on the target machine. Frame gaps do not identify their cause or measure script time inside your work; time that work separately and pass it as a readout. Long-task counts depend on the browser's reporting support.
This page is docs/perf-monitor.md at v1.4.13.