livqenoDocs

RTCPre-call Test

Pre-call Test

Check a device's camera, microphone and network before the call, and show the user what's wrong.

"Can you hear me?" is a bad way to find out a network blocks calls. A pre-call test checks everything a call needs on a lobby or settings screen, in about six seconds, and tells the user what to fix: allow the camera, leave the VPN, try another network.

It checks, in order:

CheckWhat it tells you
Camera and microphonePermission granted, a device exists, it isn't busy in another app.
TURN relaysWhether a relay can be reached over UDP, TCP and TLS separately. A network that blocks UDP still holds a call over TCP or TLS.
SignalingThe app can reach Livqeno and join a room.
MediaAudio and video can actually get through to the media server, and how: directly, or through a relay.
NetworkRound-trip time, jitter, packet loss, and upload bandwidth, measured for 5 seconds.

Run it

The test really connects, so it needs a token like any call. Mint it for a throwaway room, a new name every time, never the room the user is about to join: otherwise everyone already in that room sees a participant appear for a few seconds.

app/api/preflight-token/route.ts
import { randomUUID } from 'node:crypto';
import { livqeno } from '@/lib/livqeno';
 
export async function POST() {
  const room = await livqeno.rooms.create({ name: `preflight-${randomUUID()}` });
  const grant = await livqeno.tokens.create({
    room: room.id,
    identity: `preflight-${randomUUID()}`,
    permissions: { join: true, subscribe: true, publish: true },
    expiresIn: 120,
  });
  return Response.json(grant); // forward the whole object
}

Then, in the browser:

import { runConnectionTest } from '@livqeno/rtc';
 
const grant = await fetch('/api/preflight-token', { method: 'POST' }).then((r) => r.json());
 
const report = await runConnectionTest({
  token: grant.token,
  endpoint: grant.endpoint,
  iceServers: grant.iceServers,   // needed for the TURN checks
  durationMs: 5000,               // how long to measure; the default
  onProgress: (stage) => setStep(stage), // 'devices' → 'turn' → 'signaling' → 'media' → 'measuring' → 'done'
});
 
if (report.ok) {
  showReady(report.issues);       // ok can still carry warnings
} else {
  showTroubleshooting(report.issues);
}

runConnectionTest() never throws for a failed check. A failure is a result, in the report. It only throws when token or endpoint is missing. Turn off a device check with camera: false or microphone: false.

It lives in its own module: an app that never calls it doesn't ship it.

The report

{
  ok: true,                         // signaling and media both connected
  camera:     { status: 'passed', devices: 2 },
  microphone: { status: 'failed', devices: 1, error: 'MICROPHONE_PERMISSION_DENIED', message: '…' },
  signaling:  { status: 'passed', durationMs: 412 },
  media:      { status: 'passed', durationMs: 893 },
  route: {
    candidateType: 'relay',         // 'host' | 'srflx' | 'prflx' | 'relay'
    usedRelay: true,
    protocol: 'udp',
    relayProtocol: 'tls',           // how this device reached the relay
  },
  turn: { udp: 'failed', tcp: 'passed', tls: 'passed' },  // 'skipped' = no such server configured
  network: { rttMs: 84, jitterMs: 6, packetLossPercent: 0.4, upstreamKbps: 1480 },
  issues: [
    { code: 'MICROPHONE_UNAVAILABLE', severity: 'warning', message: '…' },
    { code: 'RELAY_REQUIRED', severity: 'warning', message: '…' },
    { code: 'UDP_BLOCKED', severity: 'warning', message: '…' },
  ],
}

Any number the test couldn't measure is left out (undefined), never reported as zero. "No loss measured" and "0% loss" are different answers.

issues is ordered by how much it matters, and every message is written to show an end user as is. The codes are stable, so you can map them to your own text:

CodeSeverityMeans
CONNECT_FAILEDerrorCan't reach Livqeno at all. Often a firewall or proxy blocking WebSockets, or a bad token.
MEDIA_FAILEDerrorJoined, but audio and video can't get through. The network blocks the ports calls need, and no relay worked.
CAMERA_UNAVAILABLE / MICROPHONE_UNAVAILABLEwarningSee camera.error / microphone.error: permission denied, no device, or in use.
RELAY_UNREACHABLEwarningNo relay could be reached. Calls will fail on networks that block direct connections.
RELAY_REQUIREDwarningMedia had to go through a relay. It works, with a little more delay.
UDP_BLOCKEDwarningMedia falls back to TCP. Expect more delay and choppier video.
HIGH_LATENCYwarningRound trip over 300 ms: people will talk over each other.
HIGH_JITTERwarningJitter over 30 ms: audio may sound choppy.
PACKET_LOSSwarningOver 3% of packets lost on the way out.
LOW_UPSTREAMwarningUnder 300 kbit/s upload with the camera on: others will see you at low quality.

React Native

The same function, with the WebRTC setup done for you, so it works on a lobby screen before you create Livqeno:

import { permissions, runConnectionTest } from '@livqeno/react-native';
 
await permissions.request(['camera', 'microphone']); // ask first on Android
const report = await runConnectionTest({ token, endpoint, iceServers });

Flutter doesn't have a pre-call test yet.

Common errors

ProblemCauseFix
turn is all skippediceServers wasn't passed.Forward grant.iceServers from the token response.
Others saw a stranger joinThe token named a real room.Mint preflight tokens for a fresh preflight-… room every time.
camera.status is failed with CAMERA_PERMISSION_DENIEDThe user blocked the camera.Show how to allow it in the browser's site settings, then run the test again.
media failed but turn.tcp or turn.tls passedThe relay works, but the call didn't get there in time.Run it again; if it keeps failing, raise timeoutMs (default 15000).

Next steps