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).

Pre-call Self-Test

Introduction

Build a pre-call self-test screen where users record a short loopback of their own microphone, camera, and network path before joining a real call. Once recorded, they can watch the result back - what they see and hear is exactly what the round trip through Stream's SFU delivers to other participants.

This recipe uses the loopback recording API. The result is a single screen with three states: device preview, recording, and playback.

Implementation

Info:

Consider using a dedicated call type (for example pre-call-test) and a recognisable call ID (for example prefixed with pre_call_test_) for the loopback call. That keeps self-tests cleanly separated from real call sessions in logs, analytics, and the Stream dashboard, and lets you scope permissions and defaults independently.

The screen renders inside <StreamCall> on a throwaway call and owns the join → record → leave sequence itself. Do not join when the screen mounts: allowOwnTracksLoopback is fixed at join time, and joining a call has billing and call-analytics consequences, so it should follow a deliberate button press rather than a page load. The loopback recording guide shows how to create that call.

useLoopbackRecording gives you recordingState to drive the button, and loopbackAudioStream / loopbackVideoStream as live "the echo is flowing" indicators.

import { useCallback, useEffect, useRef, useState } from "react";
import {
  CallingState,
  ToggleAudioPreviewButton,
  ToggleVideoPreviewButton,
  VideoPreview,
  useCall,
  useCallStateHooks,
  useLoopbackRecording,
} from "@stream-io/video-react-sdk";

export const PreCallTest = () => {
  const call = useCall();
  const { useCallCallingState, useCameraState, useMicrophoneState } =
    useCallStateHooks();
  const callingState = useCallCallingState();
  const { isEnabled: isCameraEnabled } = useCameraState();
  const { isEnabled: isMicrophoneEnabled } = useMicrophoneState();
  const {
    startRecording,
    stopRecording,
    recordingState,
    loopbackAudioStream,
    loopbackVideoStream,
  } = useLoopbackRecording();

  const [error, setError] = useState<Error>();
  const [recordingUrl, setRecordingUrl] = useState<string>();
  const recordingUrlRef = useRef<string>();

  // the hook revokes nothing on your behalf
  const showRecording = useCallback((recording?: Blob) => {
    if (recordingUrlRef.current) URL.revokeObjectURL(recordingUrlRef.current);
    const url = recording ? URL.createObjectURL(recording) : undefined;
    recordingUrlRef.current = url;
    setRecordingUrl(url);
  }, []);

  useEffect(
    () => () => {
      if (recordingUrlRef.current) URL.revokeObjectURL(recordingUrlRef.current);
    },
    [],
  );

  const runTest = useCallback(async () => {
    if (!call) return;
    setError(undefined);
    showRecording(undefined); // back to preview while the next test runs
    try {
      await call.join({ create: true, allowOwnTracksLoopback: true });
      const recording = await startRecording({ includeVideo: isCameraEnabled });
      if (recording) showRecording(recording.blob);
    } catch (e) {
      setError(e instanceof Error ? e : new Error(String(e)));
    } finally {
      call.leave().catch(console.error);
    }
  }, [call, startRecording, showRecording, isCameraEnabled]);

  const isRecording = recordingState === "recording";
  const isConnecting =
    (callingState === CallingState.JOINING ||
      callingState === CallingState.JOINED) &&
    !isRecording;

  const onClick = () => (isRecording ? stopRecording() : runTest());

  const label = isRecording
    ? "Stop recording"
    : isConnecting
      ? "Connecting…"
      : "Record loopback";

  return (
    <div className="pre-call-test">
      <h1>Test your camera and microphone</h1>
      {error && <p role="alert">{error.message}</p>}

      {recordingUrl ? (
        <video src={recordingUrl} controls playsInline />
      ) : (
        <>
          <VideoPreview />
          <div>
            <ToggleAudioPreviewButton Menu={null} />
            <ToggleVideoPreviewButton Menu={null} />
          </div>
          <div role="status">
            {loopbackAudioStream ? "Audio echo live" : "Waiting for audio echo"}
            {loopbackVideoStream ? "Video echo live" : "Waiting for video echo"}
          </div>
        </>
      )}

      {!isMicrophoneEnabled && !isRecording && (
        <p role="status">
          Enable your microphone to run the test — a loopback recording always
          includes audio. Your camera is optional.
        </p>
      )}

      <button
        type="button"
        onClick={onClick}
        disabled={isConnecting || !isMicrophoneEnabled}
      >
        {label}
      </button>
    </div>
  );
};

Surfacing stats during the test

useCallStatsReport() works inside the loopback call too. Render latency, jitter, and bitrate while the user is recording, so connectivity issues surface even when the playback looks fine.

See the Call Statistics recipe for a panel you can drop in, and the Call Stats Report guide for the shape of the report.

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>