Skip to content

Latency timeline

Per-turn latency bars or a sparkline, with thresholds and a hover readout.

Status
Beta
Category
Voice
Adoption
Not used yet
import { LatencyTimeline } from "@oration/canon/components/voice/latency-timeline";
packages/canon/src/components/voice/latency-timeline.tsx

Latency

Nora's call with Northwind Freight, 14 agent turns

p50
700 ms
p95
1,310 ms
Last
660 ms
On target
  • STT
  • LLM
  • TTS
  • Other
import { toast } from "@oration/canon/components/toast";import { LatencyTimeline, type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";export function Hero() {    const turns: LatencyTurn[] = [        [640, 410, 180, 260, 140],        [720, 450, 190, 300, 150],        [910, 520, 210, 420, 170],        [680, 430, 170, 290, 150],        [1310, 780, 240, 760, 190],        [760, 470, 180, 330, 160],        [590, 380, 160, 240, 130],        [1040, 610, 200, 560, 170],        [700, 440, 180, 300, 150],        [650, 420, 170, 270, 140],        [1180, 690, 220, 640, 180],        [620, 400, 170, 250, 140],        [730, 460, 190, 310, 150],        [660, 420, 170, 280, 140],    ].map(([e2eMs = 0, ttfbMs = 0, sttMs = 0, llmMs, ttsMs], i) => ({        id: `turn-${i + 1}`,        index: i + 1,        e2eMs,        ttfbMs,        sttMs,        llmMs,        ttsMs,        speaker: "agent" as const,    }));    return (        <section            aria-labelledby="latency-hero-title"            className="flex w-full max-w-2xl flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border"        >            <div className="flex flex-col gap-0.5">                <h3 id="latency-hero-title" className="text-sm font-semibold">                    Latency                </h3>                <p className="text-xs text-muted-foreground">                    Nora's call with Northwind Freight, 14 agent turns                </p>            </div>            <LatencyTimeline                turns={turns}                height={120}                onTurnSelect={(turn) => {                    const seconds = 6 + (turn.index - 1) * 11;                    toast.add({                        title: `Jumped to turn ${turn.index}`,                        description: `The recording moved to ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}.`,                    });                }}            />        </section>    );}

Usage#

Latency timeline charts how long a voice agent such as Nora took to answer, turn by turn: each bar is one turn's end-to-end latency, stacked into speech-to-text, LLM and text-to-speech in ink steps, against a dashed target and budget line. A summary above gives p50, p95 and the last turn with a status label, and hovering or arrowing through turns opens a readout with first-byte time (TTFB) and each stage in milliseconds. It's how people find the slow turn in a supplier call and jump to it. The common mistake is coloring bars by threshold; the bars stay ink and the status label carries the verdict.

When to use

  • On a call's detail page, beside the transcript, so a click on a slow turn jumps playback there.
  • In the metrics tab of a live web call with Nora, with bars growing in as she answers.
  • When people tune an agent's speed and need to see which stage (STT, LLM or TTS) dominates.
  • As a sparkline in a narrow panel, such as a web call's metrics tab, where only the shape of end-to-end latency matters.

When not to use

  • For a single latency figure in a table cell or a list row. Use Latency sparkline
  • For latency across many calls over days or weeks, with axes and a legend. Use Chart
  • For a percentage or count with a label, such as containment rate. Use Stat strip
  • For the words of the turns themselves. Use Live transcript
  • For a generic small trend that isn't per-turn latency. Use Mini chart

The Ink Fill Rule

Bars are magnitudes, so they're ink: STT, LLM and TTS stack from lighter to darker, with the remainder faintest. Thresholds are dashed lines, not bar colors.

The Label-Beside-Color Rule

The verdict on the last turn, and on any turn in the readout, is a status label with words: On target, Slow or Over budget.

The Tabular Figures Rule

Every number (p50, p95, the axis labels, the readout) is set in tabular figures, in milliseconds with a thousands separator: 1,310 ms.

Anatomy#

p50
700 ms
p95
1,310 ms
Last
700 ms
On target
  • STT
  • LLM
  • TTS
  • Other
  1. Summary. p50, p95 and Last: a 12px Slate Meta label above a 14px medium value. A dash until the first turn.
  2. Status. The last turn's verdict as a status label, right-aligned.
  3. Bars. One per turn, at most 16px wide with 2px gaps and 3px top corners: STT, LLM, TTS and the remainder stacked bottom to top.
  4. Target line. Dashed ink at 20% at thresholds.good (800 ms by default).
  5. Budget line. Dashed Signal Red at 45% at thresholds.warn (1,200 ms by default).
  6. Axis labels. Target and Budget with their values in 11px Slate Meta, in a 48px column on the right.
  7. Legend. 11px swatches for STT, LLM, TTS and Other. Bars only.
  8. Readout. On hover or focus: a 176px popover card with the turn number, speaker, end to end, first byte, each stage and its status. It stays inside the plot's edges.

Examples#

Bars and sparkline

Bars break each turn into STT, LLM and TTS. The sparkline plots end-to-end latency only, for narrow spaces. Hover either, or focus it and use the arrow keys.

Bars
p50
700 ms
p95
1,310 ms
Last
650 ms
On target
  • STT
  • LLM
  • TTS
  • Other
Sparkline
p50
700 ms
p95
1,310 ms
Last
650 ms
On target
import { LatencyTimeline, type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";export function Variants() {    const turns: LatencyTurn[] = [        [640, 410, 180, 260, 140],        [720, 450, 190, 300, 150],        [910, 520, 210, 420, 170],        [680, 430, 170, 290, 150],        [1310, 780, 240, 760, 190],        [760, 470, 180, 330, 160],        [590, 380, 160, 240, 130],        [1040, 610, 200, 560, 170],        [700, 440, 180, 300, 150],        [650, 420, 170, 270, 140],    ].map(([e2eMs = 0, ttfbMs = 0, sttMs = 0, llmMs, ttsMs], i) => ({        id: `v-${i + 1}`,        index: i + 1,        e2eMs,        ttfbMs,        sttMs,        llmMs,        ttsMs,    }));    return (        <div className="grid w-full max-w-3xl gap-8 text-left sm:grid-cols-2">            <div className="flex min-w-0 flex-col gap-2">                <span className="text-xs text-muted-foreground">Bars</span>                <LatencyTimeline turns={turns} />            </div>            <div className="flex min-w-0 flex-col gap-2">                <span className="text-xs text-muted-foreground">Sparkline</span>                <LatencyTimeline turns={turns} variant="sparkline" />            </div>        </div>    );}

Thresholds

The same call against the default budget and a stricter one. The lines move, the status follows, and the bars stay ink.

Default, target 800 ms and budget 1,200 ms
p50
720 ms
p95
1,010 ms
Last
700 ms
On target
Payments desk, target 600 ms and budget 900 ms
p50
720 ms
p95
1,010 ms
Last
700 ms
Slow
import { LatencyTimeline, type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";export function Thresholds() {    const turns: LatencyTurn[] = [        [640, 410, 180, 260, 140],        [720, 450, 190, 300, 150],        [910, 520, 210, 420, 170],        [680, 430, 170, 290, 150],        [1010, 600, 210, 610, 190],        [760, 470, 180, 330, 160],        [590, 380, 160, 240, 130],        [820, 500, 200, 450, 170],        [700, 440, 180, 300, 150],    ].map(([e2eMs = 0, ttfbMs = 0, sttMs = 0, llmMs, ttsMs], i) => ({        id: `t-${i + 1}`,        index: i + 1,        e2eMs,        ttfbMs,        sttMs,        llmMs,        ttsMs,    }));    return (        <div className="grid w-full max-w-3xl gap-8 text-left sm:grid-cols-2">            <div className="flex min-w-0 flex-col gap-2">                <span className="text-xs text-muted-foreground">                    Default, target 800 ms and budget 1,200 ms                </span>                <LatencyTimeline turns={turns} showLegend={false} />            </div>            <div className="flex min-w-0 flex-col gap-2">                <span className="text-xs text-muted-foreground">                    Payments desk, target 600 ms and budget 900 ms                </span>                <LatencyTimeline                    turns={turns}                    thresholds={{ good: 600, warn: 900 }}                    showLegend={false}                />            </div>        </div>    );}

During a live call

New turns grow in from the baseline. With maxTurns={12} the window slides, while p50 and p95 still count every turn.

Web call with Nora, last 12 of 3 turns
p50
720 ms
p95
910 ms
Last
910 ms
Slow
  • STT
  • LLM
  • TTS
  • Other
import { LatencyTimeline, type LatencyTurn } from "@oration/canon/components/voice/latency-timeline";import * as React from "react";export function LiveCall() {    const script = [        [640, 410, 180, 260, 140],        [720, 450, 190, 300, 150],        [910, 520, 210, 420, 170],        [680, 430, 170, 290, 150],        [1310, 780, 240, 760, 190],        [760, 470, 180, 330, 160],        [590, 380, 160, 240, 130],        [1040, 610, 200, 560, 170],        [700, 440, 180, 300, 150],        [650, 420, 170, 270, 140],        [1180, 690, 220, 640, 180],        [620, 400, 170, 250, 140],        [730, 460, 190, 310, 150],        [660, 420, 170, 280, 140],        [880, 510, 200, 470, 160],        [610, 390, 160, 250, 140],    ];    const [count, setCount] = React.useState(3);    React.useEffect(() => {        if (count >= script.length) return;        const id = window.setTimeout(() => setCount((n) => n + 1), 1400);        return () => window.clearTimeout(id);    }, [count, script.length]);    const turns: LatencyTurn[] = script        .slice(0, count)        .map(([e2eMs = 0, ttfbMs = 0, sttMs = 0, llmMs, ttsMs], i) => ({            id: `live-${i + 1}`,            index: i + 1,            e2eMs,            ttfbMs,            sttMs,            llmMs,            ttsMs,            speaker: "agent" as const,        }));    return (        <div className="flex w-full max-w-md flex-col gap-2 text-left">            <span className="text-xs text-muted-foreground tabular-nums">                Web call with Nora, last 12 of {count} turns            </span>            <LatencyTimeline turns={turns} maxTurns={12} height={112} />        </div>    );}

In a metrics panel

The web call's metrics tab pairs a sparkline with a stage table in a tint well, using the exported percentile and formatMs so the figures match the chart.

Metrics

p50
720 ms
p95
1,310 ms
Last
1,040 ms
Slow
Latency by stage
Stagep50p95
First byte450 ms780 ms
STT180 ms240 ms
LLM300 ms760 ms
TTS150 ms190 ms
import { formatMs, LatencyTimeline, type LatencyTurn, percentile } from "@oration/canon/components/voice/latency-timeline";export function SidePanel() {    const turns: LatencyTurn[] = [        [640, 410, 180, 260, 140],        [720, 450, 190, 300, 150],        [910, 520, 210, 420, 170],        [680, 430, 170, 290, 150],        [1310, 780, 240, 760, 190],        [760, 470, 180, 330, 160],        [590, 380, 160, 240, 130],        [1040, 610, 200, 560, 170],    ].map(([e2eMs = 0, ttfbMs = 0, sttMs = 0, llmMs, ttsMs], i) => ({        id: `p-${i + 1}`,        index: i + 1,        e2eMs,        ttfbMs,        sttMs,        llmMs,        ttsMs,    }));    const stages = [        { label: "First byte", values: turns.map((t) => t.ttfbMs) },        { label: "STT", values: turns.map((t) => t.sttMs) },        { label: "LLM", values: turns.map((t) => t.llmMs ?? 0) },        { label: "TTS", values: turns.map((t) => t.ttsMs ?? 0) },    ];    return (        <div className="flex w-full max-w-xs flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border">            <h3 className="text-sm font-semibold">Metrics</h3>            <LatencyTimeline turns={turns} variant="sparkline" height={96} />            <div className="rounded-[10px] bg-muted/70 px-3 py-2">                <table className="w-full text-13 tabular-nums">                    <caption className="sr-only">Latency by stage</caption>                    <thead>                        <tr className="text-xs text-muted-foreground">                            <th                                scope="col"                                className="py-1 text-left font-normal"                            >                                Stage                            </th>                            <th                                scope="col"                                className="py-1 text-right font-normal"                            >                                p50                            </th>                            <th                                scope="col"                                className="py-1 text-right font-normal"                            >                                p95                            </th>                        </tr>                    </thead>                    <tbody>                        {stages.map((stage) => (                            <tr key={stage.label}>                                <th                                    scope="row"                                    className="py-1 text-left font-normal text-muted-foreground"                                >                                    {stage.label}                                </th>                                <td className="py-1 text-right">                                    {formatMs(percentile(stage.values, 50))}                                </td>                                <td className="py-1 text-right">                                    {formatMs(percentile(stage.values, 95))}                                </td>                            </tr>                        ))}                    </tbody>                </table>            </div>        </div>    );}

States#

Empty
p50
—
p95
—
Last
—
Latency shows up after the first turn.
On target
p50
680 ms
p95
910 ms
Last
640 ms
On target
Slow
p50
720 ms
p95
1,050 ms
Last
1,050 ms
Slow
Over budget
p50
910 ms
p95
1,420 ms
Last
1,420 ms
Over budget
import { LatencyTimeline } from "@oration/canon/components/voice/latency-timeline";export function StatesMatrix() {    const cases = [        { label: "Empty", latencies: [] as number[] },        { label: "On target", latencies: [720, 910, 680, 640] },        { label: "Slow", latencies: [720, 680, 910, 1050] },        { label: "Over budget", latencies: [720, 910, 1180, 1420] },    ];    return (        <div className="grid w-full gap-x-8 gap-y-6 text-left sm:grid-cols-2">            {cases.map((item) => (                <div key={item.label} className="flex min-w-0 flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {item.label}                    </span>                    <LatencyTimeline                        turns={item.latencies.map((ms, i) => ({                            id: `${item.label}-${i}`,                            index: i + 1,                            e2eMs: ms,                            ttfbMs: Math.round(ms * 0.6),                            sttMs: Math.round(ms * 0.25),                            llmMs: Math.round(ms * 0.45),                            ttsMs: Math.round(ms * 0.2),                        }))}                        height={96}                        showLegend={false}                    />                </div>            ))}        </div>    );}
States
StateTreatment
EmptyA Well Gray well reads Latency shows up after the first turn. The summary shows dashes.
On targetLast turn at or under good: a green dot and On target.
SlowOver good, at or under warn: an amber dot and Slow.
Over budgetOver warn: a red dot and Over budget.
Turn activeHovering or arrowing to a turn fills its column Well Gray and opens the readout. In the sparkline, a point marks the turn.
Focus visibleThe plot draws a 2px Focus Indigo ring at 50% with a 2px offset.
New turnA turn that arrives after mount grows up from the baseline on spring.moderate. Turns present on mount don't animate.

Behavior#

  • The scale runs from 0 to the larger of 115% of warn and the slowest visible turn, so the budget line is always in view.
  • Only the last maxTurns (40) are drawn. The summary's p50, p95 and Last use every turn passed in.
  • Each bar's segments are sttMs, llmMs and ttsMs, with whatever remains of e2eMs as Other. Leave out llmMs or ttsMs and that segment and its readout row disappear.
  • Focusing the plot selects the last turn. Arrow Left and Right step through turns, Home and End jump to the first and last, Escape clears, and Enter or Space calls onTurnSelect. Each step is announced politely.
  • Click a turn to call onTurnSelect(turn), which the call detail page uses to seek the recording.
  • In the sparkline variant, the pointer's x position picks the nearest turn. There's no legend, since there are no segments.
  • The readout enters on spring.fast from 2px below and leaves in 60ms.

Do and don't#

p50
700 ms
p95
1,310 ms
Last
620 ms
On target
Do. Keep bars in ink and let the status label and the budget line say what's slow.
Don't. Color each bar green, amber or red. The chart turns into a traffic light, and the stage breakdown disappears.
Do. Set thresholds to the agent's real budget, and use the same values in any table beside it.
Don't. Leave the defaults on an agent whose budget is different. A Slow label people can't trust is worse than none.
Do. Judge a call by p95 and the slow turns, and click through to hear them.
Don't. Judge it by the average. One 2-second pause is what the supplier remembers.

Content#

  • Stage names are the industry abbreviations, STT, LLM and TTS, explained once in an InfoTip beside the section title, along with TTFB.
  • Values are milliseconds with a thousands separator and a space: 1,310 ms.
  • Pass speaker so the readout can say Agent or Caller.
  • If the section has a title, call it Latency and describe the base: 14 agent turns.

Accessibility#

  • The plot is a focusable role="group" named Latency by turn, 14 turns. Use the arrow keys to inspect a turn.
  • Each turn is announced in a polite live region as it's selected: Turn 5, 1,310 ms end to end, first byte 780 ms, STT 240 ms, LLM 760 ms, TTS 190 ms, Over budget.
  • The readout card, bars, lines and axis labels are aria-hidden; the live region carries the same information.
  • Statuses are words beside dots. Stages differ by lightness only, and the legend and readout name them.
  • Under reduced motion the app's motion config drops the grow-in and the readout's translate.
Keyboard interactions
KeysAction
TabFocuses the plot and selects the last turn.
←→Previous or next turn.
HomeFirst turn.
EndLast turn.
EnterCalls onTurnSelect for the selected turn.
SpaceCalls onTurnSelect for the selected turn.
EscClears the selection.

Design tokens#

Design tokens
TokenUsed for
--foregroundSegment fills at 30, 55, 80 and 12%; the target line at 20%; the sparkline
--destructiveBudget line at 45%; Over budget dot
--successOn target dot
--warningSlow dot
--mutedActive turn column, empty well at 70%
--muted-foregroundSummary labels, axis labels, legend
--popoverReadout card
shadow-popoverReadout lift
spring.moderateNew bars growing in

API reference#

LatencyTimeline

The chart and its summary. Also exported: the LatencyTurn and LatencyThresholds types.

Other props spread onto Nothing. Only the props below are read..

Props of LatencyTimeline
PropTypeDefaultDescription
turnsRequired{ id: string; index: number; e2eMs: number; ttfbMs: number; sttMs: number; llmMs?: number; ttsMs?: number; speaker?: "agent" | "user" }[]No defaultOne entry per turn, in order. index is the 1-based turn number shown in the readout.
thresholds{ good: number; warn: number }{ good: 800, warn: 1200 }Target and budget in ms.
variant"bars" | "sparkline""bars"Stacked bars, or an end-to-end line.
heightnumber96Plot height in px.
maxTurnsnumber40How many recent turns to draw.
showSummarybooleantruep50, p95, Last and status.
showLegendbooleantrueStage legend, bars only.
onTurnSelect(turn: LatencyTurn) => voidNo defaultClick, Enter or Space on a turn.
classNamestringNo defaultMerged onto the root.

formatMs

Formats milliseconds the way the chart does.

Props of formatMs
PropTypeDefaultDescription
msRequirednumberNo defaultRounded to a whole number.
returnsstringNo default1,310 ms.

percentile

Nearest-rank percentile.

Props of percentile
PropTypeDefaultDescription
valuesRequirednumber[]No defaultAny order. Returns 0 when empty.
pRequirednumberNo default0 to 100.

latencyStatus

The verdict for one latency.

Props of latencyStatus
PropTypeDefaultDescription
msRequirednumberNo defaultEnd-to-end latency.
tRequired{ good: number; warn: number }No defaultThresholds.
returns{ tone: StatusTone; label: "On target" | "Slow" | "Over budget" }No defaultFor a StatusLabel.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Segments use ink at 30, 55, 80 and 12%, not the system's ink steps (80, 65, 45, 25, 15).

ttfbMs is required but only appears in the readout; nothing plots it.

The plot's focus ring is 2px at 50% with an offset, not the 3px ring at 40% the rest of the suite uses.

Nearest-rank p95 equals the slowest turn on short calls (under 20 turns), which can read as harsher than expected.

latencyStatus is exported but not listed in the registry.

The Target and Budget axis labels always render, in a fixed 48px column, and there's no prop to hide them. Below about 96px of height they overlap, so the chart can't shrink into a table row.