Platform docs
Auth, users, webhooks & more
Info:
This is beta documentation for Stream Video React SDK v2. For the latest stable version, see the latest version (v1).

Call Statistics

The CallStats panel that v1 shipped is no longer part of the SDK. The data behind it is still there - useCallStatsReport() emits a full report every two seconds - so a stats panel is now something you assemble yourself, which is what this recipe does.

Doing it by hand is also what lets the panel match your product: you decide which numbers deserve screen space, what counts as a bad value, and whether stats live in a drawer, a debug overlay, or a support-only screen.

The stats panel this recipe builds

What to display

useCallStatsReport() returns aggregated publisher and subscriber stats rather than raw WebRTC dumps, so most of the panel is a direct read of the report. See the Call Stats Report guide for the full shape.

Stat Where it comes from What it tells the user
Region datacenter The edge server the participant is connected to
Latency publisherStats.averageRoundTripTimeInMs Time to deliver data between the app and the server
Receive jitter subscriberStats.averageJitterInMs Variation in the delay of incoming packets
Publish jitter publisherStats.averageJitterInMs Variation in the delay of outgoing packets
Publish resolution publisherStats.highestFrame{Width,Height,sPerSecond} The video resolution being sent
Publish quality drop reason publisherStats.qualityLimitationReasons Why outgoing quality dropped - usually cpu or bandwidth
Receiving resolution subscriberStats.highestFrame{Width,Height,sPerSecond} The video resolution being received
Publish bitrate derived from publisherStats.totalBytesSent Rate at which data leaves the app
Receiving bitrate derived from subscriberStats.totalBytesReceived Rate at which data arrives from the server
Codec publisherStats.codec, subscriberStats.codec Useful when debugging, noise for everyone else

Latency, jitter and the quality drop reasons are the three that actually explain a bad call. The rest is supporting detail - a panel that shows only those three is a legitimate panel.

Thresholds

Raw milliseconds mean nothing to most users, so color-code them. These are the bounds the v1 component used, and they are a sound starting point:

export const STATS_THRESHOLDS = {
  latency: { good: 75, poor: 400 },
  audioJitter: { good: 10, poor: 30 },
  videoJitter: { good: 20, poor: 50 },
} as const;

export type Grade = "good" | "ok" | "poor";

export const grade = (
  value: number | undefined,
  { good, poor }: { good: number; poor: number },
): Grade | undefined => {
  if (value === undefined) return undefined;
  if (value <= good) return "good";
  if (value >= poor) return "poor";
  return "ok";
};

Deriving bitrate

Bitrate is not in the report - totalBytesSent and totalBytesReceived are cumulative counters. To turn them into a rate, keep the previous report and divide the byte delta by the time delta:

import type { CallStatsReport } from "@stream-io/video-react-sdk";

export const toBitrateKbps = (
  current: number | undefined,
  previous: number | undefined,
  elapsedMs: number,
): number | undefined => {
  if (current === undefined || previous === undefined || elapsedMs <= 0) {
    return undefined;
  }
  const deltaBytes = current - previous;
  // counters reset when the peer connection is re-established
  if (deltaBytes < 0) return undefined;
  return (deltaBytes * 8) / elapsedMs;
};

Bytes times 8 over milliseconds gives kilobits per second directly, so no extra scaling is needed.

Keeping a history

A single report is a snapshot. Buffering the last N reports gives you a latency sparkline and smooths out the jumpiness of instantaneous values:

import { useEffect, useRef, useState } from "react";
import {
  useCallStateHooks,
  type CallStatsReport,
} from "@stream-io/video-react-sdk";

const HISTORY_SIZE = 20; // 20 reports x 2s = the last ~40 seconds

export const useStatsHistory = () => {
  const { useCallStatsReport } = useCallStateHooks();
  const report = useCallStatsReport();
  const [history, setHistory] = useState<CallStatsReport[]>([]);

  useEffect(() => {
    if (!report) return;
    setHistory((previous) => [...previous, report].slice(-HISTORY_SIZE));
  }, [report]);

  return { report, history };
};

Reports are emitted on a timer, so this effect runs once every two seconds - cheap enough to keep in component state.

The panel

Putting it together. previous is the report before the current one, which is what the bitrate calculation needs:

export const CallStatsPanel = () => {
  const { report, history } = useStatsHistory();

  if (!report) return <p>Gathering call statistics...</p>;

  const previous = history[history.length - 2];
  const elapsedMs = previous ? report.timestamp - previous.timestamp : 0;

  const latency = report.publisherStats?.averageRoundTripTimeInMs;
  const publishJitter = report.publisherStats?.averageJitterInMs;
  const receiveJitter = report.subscriberStats?.averageJitterInMs;

  const publishBitrate = toBitrateKbps(
    report.publisherStats?.totalBytesSent,
    previous?.publisherStats?.totalBytesSent,
    elapsedMs,
  );
  const receiveBitrate = toBitrateKbps(
    report.subscriberStats?.totalBytesReceived,
    previous?.subscriberStats?.totalBytesReceived,
    elapsedMs,
  );

  return (
    <dl className="my-call-stats">
      <Stat label="Region" value={report.datacenter} />
      <Stat
        label="Latency"
        value={latency && `${Math.round(latency)} ms`}
        grade={grade(latency, STATS_THRESHOLDS.latency)}
      />
      <Stat
        label="Publish jitter"
        value={publishJitter && `${publishJitter.toFixed(1)} ms`}
        grade={grade(publishJitter, STATS_THRESHOLDS.videoJitter)}
      />
      <Stat
        label="Receive jitter"
        value={receiveJitter && `${receiveJitter.toFixed(1)} ms`}
        grade={grade(receiveJitter, STATS_THRESHOLDS.videoJitter)}
      />
      <Stat
        label="Publish resolution"
        value={formatResolution(report.publisherStats)}
      />
      <Stat
        label="Receiving resolution"
        value={formatResolution(report.subscriberStats)}
      />
      <Stat
        label="Publish bitrate"
        value={publishBitrate && `${publishBitrate.toFixed(0)} kbps`}
      />
      <Stat
        label="Receiving bitrate"
        value={receiveBitrate && `${receiveBitrate.toFixed(0)} kbps`}
      />
      <Stat
        label="Quality drop reason"
        value={report.publisherStats?.qualityLimitationReasons}
      />
    </dl>
  );
};

The two small helpers the panel leans on:

const formatResolution = (stats?: {
  highestFrameWidth?: number;
  highestFrameHeight?: number;
  highestFramesPerSecond?: number;
}) => {
  if (!stats?.highestFrameWidth || !stats.highestFrameHeight) return undefined;
  const { highestFrameWidth, highestFrameHeight, highestFramesPerSecond } =
    stats;
  const resolution = `${highestFrameWidth}x${highestFrameHeight}`;
  return highestFramesPerSecond
    ? `${resolution}@${highestFramesPerSecond}`
    : resolution;
};

const Stat = ({
  label,
  value,
  grade,
}: {
  label: string;
  value?: string | number;
  grade?: Grade;
}) => (
  <div className={grade ? `my-call-stats__stat--${grade}` : undefined}>
    <dt>{label}</dt>
    <dd>{value ?? "-"}</dd>
  </div>
);

Every value is optional. A report arriving before the first frame is published has no publisher resolution, and qualityLimitationReasons is absent while nothing is limiting quality - which is the common case, and worth rendering as nothing rather than as an empty row.

Where to put it

Stats are a diagnostic, not a feature. Two placements that work:

  • Behind a toggle in the call controls, for calls where users are expected to troubleshoot their own connection. See replacing call controls.
  • In a support-only overlay, gated behind a query parameter or a staff flag, so your support team can read the numbers off a user's screen without the panel existing for everyone else.

The reporting interval is configurable, and stats gathering can be switched off entirely for builds that never show a panel:

// report every 3.5 seconds instead of the default 2
call.setStatsReportingIntervalInMs(3500);

// or turn stats gathering off
call.setStatsReportingIntervalInMs(0);

Keep the interval at two seconds or above - anything faster burns CPU for numbers that no one can read that quickly.

Add Chat to my app: getstream.io/SKILL.md

The fastest way to build with Stream. Start a new project or improve an existing one. Full CLI and documentation integration out of the box.


Ask your agent:

/stream Build me a Social App with Feeds and Moderation.
/stream Any livestream calls running?
/stream Video React v2: <Your Question>