"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:
| Check | What it tells you |
|---|---|
| Camera and microphone | Permission granted, a device exists, it isn't busy in another app. |
| TURN relays | Whether a relay can be reached over UDP, TCP and TLS separately. A network that blocks UDP still holds a call over TCP or TLS. |
| Signaling | The app can reach Livqeno and join a room. |
| Media | Audio and video can actually get through to the media server, and how: directly, or through a relay. |
| Network | Round-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.
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:
| Code | Severity | Means |
|---|---|---|
CONNECT_FAILED | error | Can't reach Livqeno at all. Often a firewall or proxy blocking WebSockets, or a bad token. |
MEDIA_FAILED | error | Joined, but audio and video can't get through. The network blocks the ports calls need, and no relay worked. |
CAMERA_UNAVAILABLE / MICROPHONE_UNAVAILABLE | warning | See camera.error / microphone.error: permission denied, no device, or in use. |
RELAY_UNREACHABLE | warning | No relay could be reached. Calls will fail on networks that block direct connections. |
RELAY_REQUIRED | warning | Media had to go through a relay. It works, with a little more delay. |
UDP_BLOCKED | warning | Media falls back to TCP. Expect more delay and choppier video. |
HIGH_LATENCY | warning | Round trip over 300 ms: people will talk over each other. |
HIGH_JITTER | warning | Jitter over 30 ms: audio may sound choppy. |
PACKET_LOSS | warning | Over 3% of packets lost on the way out. |
LOW_UPSTREAM | warning | Under 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
| Problem | Cause | Fix |
|---|---|---|
turn is all skipped | iceServers wasn't passed. | Forward grant.iceServers from the token response. |
| Others saw a stranger join | The token named a real room. | Mint preflight tokens for a fresh preflight-… room every time. |
camera.status is failed with CAMERA_PERMISSION_DENIED | The 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 passed | The relay works, but the call didn't get there in time. | Run it again; if it keeps failing, raise timeoutMs (default 15000). |
Next steps
- Diagnostics: the same numbers during a call.
- Reconnection & network quality: the live quality score.
- Troubleshooting