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

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 - with the SDK's <StatCard />, or your own markup - while the user is recording, so connectivity issues surface even when the playback looks fine.

See the Call Stats Report guide for the shape of the report.