Elements

Elements · AUI connected · AUI

Message timing

Streaming statistics for the current message, including first token, total time, and speed.

Badge
On hover
First token312ms
Total1.23s
Speed82.5 tok/s
Chunks47
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/message-timing"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

Message timing turns a stream's own telemetry into a small badge: a duration on the trigger, and first token time, total time, speed, and chunk count in its tooltip. With a runtime it reads the timing recorded on the current assistant message; there is no standalone form of this exact badge, since a badge with nothing to time has nothing to show. It comes in two designs: the runtime variant reads that live telemetry into a hover tooltip, and the static variant renders the same numbers as a props-driven stats row instead (see The timing-footer design).

Getting started

Every assistant-ui runtime records how a response streamed in. The badge reads that recording through useMessageTiming() and stays invisible until it has something to report.

Read the message's timing

useMessageTiming() returns the current assistant message's metadata.timing, or undefined on a user message or before any timing exists. The badge renders nothing until totalStreamTime is set, so it appears only once a response has actually finished streaming.

components/assistant-ui/elements/message-timing.aui.tsx
"use client";

import { useMessageTiming } from "@assistant-ui/react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import type { FC } from "react";

const formatTimingMs = (ms: number | undefined): string => {
  if (ms === undefined) return "—";
  if (ms < 1000) return `${Math.round(ms)}ms`;
  return `${(ms / 1000).toFixed(2)}s`;
};

export const MessageTiming: FC<{
  className?: string;
  side?: "top" | "right" | "bottom" | "left";
}> = ({ className, side = "right" }) => {
  const timing = useMessageTiming();
  if (timing?.totalStreamTime === undefined) return null;

  return (
    <TooltipProvider>
      <Tooltip>
        <TooltipTrigger
          render={
            <button
              type="button"
              aria-label="Message timing"
              className={className}
            />
          }
        >
          {formatTimingMs(timing.totalStreamTime)}
        </TooltipTrigger>
        <TooltipContent side={side} sideOffset={8}>
          {/* first token, total, speed, chunks; see Anatomy below */}
        </TooltipContent>
      </Tooltip>
    </TooltipProvider>
  );
};

Add it to the message's action bar

Drop MessageTiming next to copy and reload so it inherits the action bar's own hover and autohide behavior from ActionBarPrimitive.Root.

components/assistant-ui/elements/thread.aui.tsx
import { ActionBarPrimitive } from "@assistant-ui/react";
import { TooltipIconButton } from "@/components/assistant-ui/elements/tooltip-icon-button";
import { MessageTiming } from "@/components/assistant-ui/elements/message-timing.aui";

function AssistantActionBar() {
  return (
    <ActionBarPrimitive.Root hideWhenRunning autohide="not-last">
      <ActionBarPrimitive.Copy asChild>
        <TooltipIconButton tooltip="Copy">{/* ... */}</TooltipIconButton>
      </ActionBarPrimitive.Copy>
      <MessageTiming />
    </ActionBarPrimitive.Root>
  );
}

Thread ships this action bar without MessageTiming in it; add the line above to turn timing on for every assistant message.

Anatomy

<button aria-label="Message timing">{/* e.g. "1.24s" */}</button>
<div>
  <div>First token   <span>{/* only when firstTokenTime is set */}</span></div>
  <div>Total         <span>{/* always, once the badge is visible */}</span></div>
  <div>Speed         <span>{/* only when tokensPerSecond is set */}</span></div>
  <div>Chunks        <span>{/* always, once the badge is visible */}</span></div>
</div>

The trigger itself is the gate: while totalStreamTime is undefined the whole badge is null, so a message still streaming shows nothing rather than a placeholder. Once it is visible, "Total" and "Chunks" always print; "First token" and "Speed" each drop out on their own when the underlying value is missing.

Examples

Tooltip side

Pass side to flip which edge the tooltip opens toward, matching wherever the action bar sits in your layout.

<MessageTiming side="left" />

Formatting rule

A duration under a second renders as whole milliseconds (420ms); a second or more renders with two decimal places (1.24s). Speed always renders as tokens per second to one decimal place (38.4 tok/s), and this same formatTimingMs rule is what the trigger itself uses for the total.

API reference

Message state

SelectorTypeDescription
s.message.metadata.timingMessageTiming | undefinedTiming recorded for the current assistant message; undefined on a user message or before any timing exists. useMessageTiming() wraps exactly this selector.

MessageTiming carries streamStartTime, firstTokenTime?, totalStreamTime?, tokenCount?, tokensPerSecond?, totalChunks, and toolCallCount.

MessageTiming props

PropTypeDefaultDescription
classNamestringMerged onto the trigger button.
side"top" | "right" | "bottom" | "left""right"Side the tooltip opens toward.

The Static variant in the rail is a second design for the same telemetry: MessageTiming lays the numbers out as an always-visible row of plain props, instead of tucking them into a badge's hover tooltip. It is a single props-driven component with no runtime dependency:

npx shadcn@latest add "@assistant-ui/elements-message-timing"

A runtime already exposes the same timing through useMessageTiming(); map it into stats yourself when you want a row instead of a badge:

components/assistant-ui/elements/thread.aui.tsx
"use client";

import { useMessageTiming } from "@assistant-ui/react";
import {
  MessageTiming,
  type TimingStat,
} from "@/components/assistant-ui/elements/message-timing";

const formatMs = (ms: number) =>
  ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;

function TimingFooter() {
  const timing = useMessageTiming();
  if (!timing) return null;

  const stats: TimingStat[] = [
    timing.firstTokenTime !== undefined && {
      label: "ttft",
      value: formatMs(timing.firstTokenTime),
    },
    timing.totalStreamTime !== undefined && {
      label: "total",
      value: formatMs(timing.totalStreamTime),
    },
    timing.tokensPerSecond !== undefined && {
      label: "tok/s",
      value: timing.tokensPerSecond.toFixed(1),
    },
    { label: "chunks", value: String(timing.totalChunks) },
  ].filter((stat): stat is TimingStat => stat !== false);

  return <MessageTiming stats={stats} />;
}

The row fades in on mount and wraps to multiple lines past its max width. Passing streaming highlights every value in blue instead of the settled muted tone, useful while the stats are still updating live; stats can be any length, including empty, which renders a bare row with nothing in it.

MessageTiming

PropTypeDefaultDescription
statsreadonly TimingStat[]required{ label: string; value: string }[], rendered in order.
streamingbooleanHighlights every value in blue while true.
classNamestringMerged onto the root.

All other div props are forwarded to the root.