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