AI

Throughput Meter

A live tokens-per-second readout — rolling sparkline, time to first token, current-vs-average delta, a stall flag raised by the meter's own clock, and a run summary when the stream ends.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Minus, TrendingDown, TrendingUp, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Throughput Meter
 *
 * "How fast is the model writing right now?" — a live tokens/second readout
 * with a rolling trend, the time-to-first-token that preceded it, how the
 * current rate compares with the run's own average, a flag when the gap
 * between tokens grows, and a summary once the run ends.
 *

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/throughput-meter.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ThroughputMeter" component (lucide-react
for icons; no chart library — the sparkline is hand-drawn SVG). It reports how
fast a model is generating right now, and what the run added up to when it ends.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
  minus children. Merge className with cn().
- tokens: number — tokens received SO FAR (a running count, not a delta).
- startedAt?: number — epoch ms the request was sent (drives TTFT + duration).
- firstTokenAt?: number — epoch ms the first token landed. Derived internally
  from the first non-zero `tokens` when omitted.
- endedAt?: number — epoch ms the run ended. Required for a run that finished
  before the component mounted; without it there is no honest end.
- status?: "idle" | "streaming" | "complete" | "error"  (default "streaming").
- runId?: string — run identity; defaults to String(startedAt). Changing it
  clears samples, trend and peak.
- variant?: "panel" | "compact" (default "panel").
- windowMs?: number (3000) — trailing window the current rate is measured over.
- sampleMs?: number (250) — sampler beat, also one sparkline bucket.
- historyLength?: number (48) — buckets kept, so ~12 s of trend by default.
- stallAfterMs?: number (2500) — silence that raises the stall flag.
- stalled?: boolean — override; undefined means "use my own detection".
- averageFrom?: "first-token" | "request-start" (default "first-token").
- unit?: string ("tok/s"), unitLabel?: string ("tokens per second"),
  label?: string (overrides the phase caption).
- showSparkline / showStats / showComparison?: boolean (all true).
- announce?: "milestones" | "off" (default "milestones").
- onStallChange?: (stalled: boolean) => void — fires on the edge only.
- Take NO pre-computed rate and NO array of samples. The caller's stream
  handler must stay `setTokens(n => n + chunk.length)`; every derived number
  below belongs to the component.

Behavior — sampling (the core)
- Keep one piece of state ("track"): a trailing array of {at, tokens} samples,
  an array of bucket rates, the newest observation clock, the token count at
  that clock, firstTokenAt, lastChangeAt and the peak rate.
- TWO writers, deliberately separate:
  * arrival bookkeeping — runs when the `tokens` prop changes; records the
    clock, firstTokenAt and lastChangeAt. It must NOT push a bucket.
  * the sampler — one setInterval at sampleMs, alive only while
    status === "streaming"; pushes a time-uniform bucket and prunes the window.
  Keeping arrivals out of the buckets is what makes the sparkline's x axis
  uniform in TIME rather than in tokens.
- Do the arrival bookkeeping DURING RENDER with the prev-props pattern
  (`const [prev, setPrev] = useState(tokens); if (prev !== tokens) {…}`), not in
  an effect: the gap it records is what the stall detector reads, and an effect
  would mark it one paint late.
- Bucket rate = (headTokens - anchorTokens) / (headAt - anchorAt), where the
  anchor is the oldest sample still inside `now - windowMs` (or the oldest one
  there is while the window warms up). Never rate = 1 / lastGap: a single
  inter-token gap is not a throughput.
- The live figure is the NEWEST BUCKET, not a reading recomputed at render
  time. The beat owns the repaint — a burst landing between two beats cannot
  make the number twitch — and the figure always equals the dot at the end of
  the sparkline, so the two never disagree.
- Clamp the anchor so the window never reaches back across firstTokenAt. A 2 s
  wait followed by four fast tokens is not "2 tok/s"; folding the queue wait
  into the divisor makes every model look slow for the first windowMs of its
  answer. Before the first token there is no reading at all — a run waiting on
  its first byte has not started, so it is an em dash, not a zero.
- If the time base is under ~250 ms, return NO reading. Print an em dash rather
  than flashing "480 tok/s" for one frame, and skip the bucket instead of
  pushing a 0 — a false dip at the start of every run reads as a stutter.
- Average = tokens / (clock - averageBase) where averageBase is firstTokenAt
  ("generation speed") or startedAt ("wall-clock speed"), per averageFrom.
- Prune samples to the window plus one anchor, cap the array hard (240), and
  cap the bucket ring at historyLength. Memory must not grow with run length.

Behavior — the clock, purity and run identity
- Render must never call Date.now(). The clock is the newest OBSERVATION, so
  two renders with the same state produce the same text; time only advances
  because the sampler produced a new state.
- status "complete" / "error" freezes the clock at endedAt (falling back to the
  last observed change) and switches the headline from the window rate to the
  RUN AVERAGE — the question changes from "how fast now" to "how fast was it".
- A run change (runId / startedAt) wipes the track during render, before paint,
  so no frame mixes the old sparkline with the new numbers. A `tokens` value
  lower than the last one is a retry that reused the identity: reset the same
  way instead of drawing a negative rate.

Behavior — stall detection
- A stall is the ABSENCE of an event: while the stream is silent no prop
  changes, so the sampler interval is the only thing that can notice it. Raise
  the flag when clock - lastChangeAt >= stallAfterMs while streaming.
- Distinguish the two silences: before the first token the flag reads "No first
  token" (the request may still be queued); after it, "Stalled" with the live
  gap next to it.
- `stalled` as a prop overrides detection in both directions, for transports
  that already know (a dead socket, a lapsed heartbeat). Fire onStallChange on
  the edge only, through a ref, so a changing inline callback cannot re-fire it.

Behavior — comparison, sparkline, summary
- Delta = (current - average) / average, shown while streaming with a ±5% dead
  band: throughput jitters, and an arrow that flips every beat is noise.
- The sparkline is bucket rates as a polyline plus a translucent area, a dashed
  line at the run average, and a dot on the newest bucket. Scale y to
  max(visible peak, average) * 1.12 so the average line is always in frame.
  Fewer than two buckets renders a dashed empty baseline — a flat line at zero
  would say "the model stopped", which is a different fact.
- The stats row is TTFT / Tokens / Elapsed, where TTFT counts UP while the
  first token is outstanding ("1.2 s…") and freezes when it lands.
- On completion print one summary line: tokens · duration · average · peak.
  "error" keeps every number it earned and only changes the verb ("Stopped
  early"), because a stopped run is still a run that happened.
- Format numbers by hand (grouping, 2/1/0 decimals as the rate grows). Do NOT
  use Intl.NumberFormat: it resolves against the runtime locale, and a
  "1,284" / "1 284" split between server and client breaks hydration.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground / border for the panel,
  text-muted-foreground for captions, tabular-nums for every figure, chart-1
  for the live trend, chart-2 for a finished one, chart-3 for the stall state
  (border-chart-3/40 + bg-chart-3/10 pill), chart-4 for a below-average delta,
  destructive for a stopped run. No hex, no rgb(), no oklch().
- panel: header (status dot + phase caption + stall pill), a 3xl figure with
  the unit and the delta line, the sparkline, then the stats row and summary.
  compact: one inline row — dot, figure, mini sparkline, avg, TTFT, stall icon.
- The pulsing status dot is the only decorative animation: ship its keyframes
  in a hoisted <style href precedence> and disable it with
  motion-reduce:[animation:none]. Sampling never stops under reduced motion —
  the numbers are the function, the pulse is the decoration.
- a11y: root role="group" with an aria-label so the reading has one name; the
  sparkline is role="img" with a spoken summary of its range; the stall pill
  carries visible text (compact keeps an sr-only copy next to the icon).
- Announcements: a separate sr-only role="status" aria-live="polite" node whose
  content is DERIVED from props — first token, stall, final summary, nothing
  else. Quote the stall THRESHOLD ("no new tokens for 3 seconds"), never the
  growing gap, or a polite region re-fires four times a second. Never announce
  the rate itself.
- Clean up on unmount: the interval is the only timer, and it is also torn down
  whenever status leaves "streaming" or the beat changes.

Customization levers
- Cadence: sampleMs is the display beat (raise to 500 ms for a calmer number,
  lower to 100 ms for a benchmark harness); windowMs is the smoothing (1000 ms
  hugs the real arrival rate, 5000 ms gives a stable average-like reading);
  historyLength × sampleMs is how much history the trend shows.
- Stall policy: stallAfterMs by transport (2.5 s for HTTP SSE, 10 s for a
  queued batch); or bypass it entirely with the `stalled` prop and pair
  onStallChange with a retry button.
- Average basis: averageFrom="request-start" when the queue wait is the
  product's problem, "first-token" when raw decode speed is.
- Density: showSparkline / showStats / showComparison strip the panel down to
  a figure; variant="compact" is the one-line form for a composer footer.
- Units: a token-billing product can pass unit="chars/s" or unit="words/s" and
  feed the matching counter — nothing in the maths assumes tokens.
- Palette: the four tones are one Record each (stroke / fill / dot); remap them
  to your own chart tokens if chart-1 is not your "live" colour.
- Layout: the stats row is a 3-column grid — add a "Peak" cell from the same
  track, or drop the summary line if a parent already reports the run.

Concepts

  • Trailing-window rate — speed is measured between the newest observation and the oldest one still inside windowMs, never between the last two tokens; one late chunk should move the number a little, not spike it to three digits.
  • Beat vs burst — tokens arrive in bursts a few milliseconds apart, but the readout repaints on a fixed beat; decoupling the two is what makes a large number readable, and it is why the sparkline's x axis is uniform in time rather than in tokens.
  • Absence as an event — a stall produces no prop change, so nothing would ever re-render to notice it; the sampler interval exists precisely to watch a gap grow, and it distinguishes "no first token yet" (still queued) from "stopped mid-answer".
  • Dead band — the current-vs-average arrow only flips outside ±5%, because a throughput reading that jitters by a token per beat would otherwise render an arrow that flickers instead of informing.
  • No reading without a time base — under a quarter second of elapsed time the divisor is noise, so the meter prints an em dash and skips the bucket instead of drawing a spike at the start of every run.
  • The run closes itself — when the status turns terminal the clock freezes at the end, the headline switches from "how fast now" to "how fast was it", and the same numbers are announced once to a polite live region that stayed quiet all the way through.

On This Page