Skip to navigation
Deploy

Troubleshooting & FAQ

View as MarkdownOpen in Claude

Common failure modes and how to diagnose them. For end-to-end patterns, see the guides; for full API contracts, see the reference.

Connection issues

InvalidCredentialsError or UnexpectedError on connect

Cause: On connect, the SDK usually reports InvalidCredentialsError because the token can’t be read as a SAT. It also reports it when the credential your provider returned is already past its expiry_at. It reports UnexpectedError with the message Unexpected Error at Error fetching user information when SignalWire refuses the SAT: it’s expired, revoked, or issued for a different SignalWire space.

Fix:

  1. Create a fresh SAT from your backend — see Authentication.
  2. Confirm the token was issued by the space you’re connecting to.
  3. Check that the full token string was copied (SATs are long; truncation is common).
import { jwtDecode } from "jwt-decode";
const decoded = jwtDecode(token);
const expiresAt = new Date(decoded.exp * 1000);
console.log("Expired?", expiresAt < new Date());

Connection fails without an obvious error

Errors that happen outside of an await flow surface on errors$. If you never subscribe to it, those errors are silently dropped — subscribe during client construction so they always reach your logs.

client.errors$.subscribe((error) => console.error("Client error:", error));

NotConnectedError when calling dial()

Cause: Calling dial() before the client finished connecting.

Wait for ready$ to emit true:

import { filter, take } from "rxjs";
client.ready$.pipe(filter(Boolean), take(1)).subscribe(async () => {
const call = await client.dial(destination);
});

WebSocket disconnects frequently

Causes: Unstable network, corporate firewall/proxy blocking WebSocket, idle timeout.

The SDK reconnects automatically. A connection banner needs two observables, because neither tells the whole story alone:

  • ready$ emits false while the SDK reconnects and true once the session has re-authenticated.
  • isConnected$ doesn’t change during an automatic reconnect. It is false before the first connect and after the client disconnects, including when the server refuses the credential on a reconnect and the SDK stops retrying.

ready$ is also false in both of those states, so a banner driven by ready$ alone would say “reconnecting” while nothing is retrying. Show “reconnecting” only while the client is connected but not ready:

import { combineLatest } from "rxjs";
combineLatest([client.isConnected$, client.ready$]).subscribe(
([connected, ready]) => {
if (ready) hideConnectionBanner();
else if (connected) showConnectionBanner("Reconnecting…");
else showConnectionBanner("Disconnected");
}
);

Video / Audio issues

Video is black

Causes: Camera permissions denied, camera in use by another app, wrong camera selected, hardware issue.

A denied or unavailable camera no longer fails the call: the SDK falls back to receive-only, so the call connects, remote video arrives, and only the local preview is empty. The reason is reported on errors$ as a non-fatal MediaAccessError — check there first, since nothing throws:

import { MediaAccessError } from "@signalwire/js";
call.errors$.subscribe(({ error }) => {
if (error instanceof MediaAccessError) {
console.log(error.media, error.denied ? "was denied" : "failed to open");
}
});
const permission = await navigator.permissions.query({ name: "camera" });
console.log("Camera permission:", permission.state);
client.videoInputDevices$.subscribe((devices) => {
console.log("Available cameras:", devices);
});

Pass fallbackToReceiveOnly: false to dial()/answer() if you would rather the call fail outright than connect without local media.

No remote audio

Causes: Output device not selected, remote participant muted, browser blocking unmuted autoplay, or the muted attribute left on the <video> element.

<!-- Wrong — the `muted` attribute mutes playback regardless of the track -->
<video id="remote" autoplay muted></video>
<!-- Right -->
<video id="remote" autoplay playsinline></video>

Browsers block autoplay with audio until the page has been interacted with — play() will reject. Catch that promise and surface a play-to-start button if it fails.

Remote can’t hear me

call.self$.subscribe((self) => console.log("Audio muted:", self?.audioMuted));
client.selectedAudioInputDevice$.subscribe((device) => {
console.log("Microphone:", device?.label);
});

Echo or feedback

Use headphones, or enable echo cancellation:

call.self$.subscribe(async (self) => {
if (self && !self.echoCancellation) await self.toggleEchoCancellation();
});

Permission denied for camera/microphone

HTTPS is required. getUserMedia only works on secure contexts (HTTPS or localhost).

try {
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
} catch (e) {
if (e.name === "NotAllowedError") {
alert("Click the lock icon in your address bar to reset permissions.");
}
}

Call issues

Call stays in trying state

Causes: Invalid destination, destination not reachable, network/firewall blocking.

// errors$ emits a CallError envelope, not a bare Error.
call.errors$.subscribe(({ error, fatal, leg }) => {
console.error("Call error:", error, { fatal, leg });
});

Call connects but no media flows

Cause: ICE connection failed (firewall blocking UDP) or TURN server unreachable.

const pc = call.rtcPeerConnection;
console.log("ICE state:", pc?.iceConnectionState);
pc?.addEventListener("iceconnectionstatechange", () => {
console.log("ICE state changed:", pc.iceConnectionState);
});

Can’t receive inbound calls

Causes: register() was never called, or the token can’t register. Only full Subscriber Access Tokens can register; Guest SATs, Invite SATs, and embed tokens are outbound-only.

Call it once. It waits for the session to authenticate, and the SDK registers again by itself after every reconnect:

await client.register();

If the SAT has expired by the time the SDK reconnects, SignalWire refuses that automatic registration. The SDK reports an InvalidCredentialsError on errors$ and isRegistered$ emits false. Create a new client with a fresh SAT, or use a credential provider that refreshes it; see Authentication.

Inbound calls then surface on session.incomingCalls$.

DTMF tones not working

Send digits only after the call is connected:

import { filter, take } from "rxjs";
call.status$
.pipe(filter((s) => s === "connected"), take(1))
.subscribe(async () => await call.sendDigits("123#"));

UI issues

Video element shows nothing

call.remoteStream$.subscribe((stream) => {
const video = document.getElementById("remoteVideo");
if (stream && video) {
video.srcObject = stream;
video.play().catch((e) => console.error("Play failed:", e));
}
});

UI doesn’t update when state changes

Subscribe immediately after getting the object — BehaviorSubjects emit current state on subscribe.

Memory leak / page slows down

Almost always a missing unsubscribe on a long-lived subscription. See RxJS Primer → Cleanup and the framework patterns in Framework Integration.

Browser-specific issues

Safari: video doesn’t play

Safari has strict autoplay policies. Add playsinline and handle the play promise:

<video id="remote" autoplay playsinline></video>
call.remoteStream$.subscribe(async (stream) => {
const video = document.getElementById("remote");
video.srcObject = stream;
try {
await video.play();
} catch {
showPlayButton(() => video.play());
}
});

Firefox: no audio output selection

Firefox doesn’t fully support setSinkId. Audio plays through default output.

Mobile: camera switches unexpectedly

Device rotation or app switching can reset the camera.

client.videoInputDevices$.subscribe((devices) => {
const preferred = devices.find((d) => d.label.includes("front"));
if (preferred) call.self?.selectVideoInputDevice(preferred);
});

Debugging

Verbose logging

The SDK emits debug-level logs to the browser console. Filter by signalwire in DevTools to isolate them.

Inspect WebSocket traffic

DevTools → Network → “WS” filter → click the connection → Messages tab.

Get call statistics

call.rtcPeerConnection is the underlying RTCPeerConnection — it’s undefined until media negotiation starts, so guard before reading it.

const stats = await call.rtcPeerConnection?.getStats();
stats?.forEach((report) => {
if (report.type === "inbound-rtp" && report.kind === "video") {
console.log("Packets received:", report.packetsReceived);
console.log("Packets lost:", report.packetsLost);
}
});

getStats() returns a single snapshot. To keep the whole call for later debugging, either download a dump from chrome://webrtc-internals when the call ends, or add rtcstats-js, an open-source library that wraps RTCPeerConnection and records periodic getStats() samples along with every peer connection event, streaming them to a server you run.

Test without real media

Chrome flag: --use-fake-device-for-media-stream. Or generate a canvas stream:

const canvas = document.createElement("canvas");
canvas.getContext("2d").fillRect(0, 0, 640, 480);
const fakeStream = canvas.captureStream(30);

FAQ

Do I need HTTPS?

Yes for production. WebRTC’s getUserMedia requires a secure context. Localhost is exempt for development.

What browsers are supported?

Modern Chrome, Firefox, Safari, and Edge. No IE11.

Can I use this in Node.js?

No — the SDK is browser-only. Use the SignalWire REST APIs or server-side SDKs.

How do I implement a mute button?

muteButton.onclick = () => call.self?.toggleMute();

call.self is null until the local participant joins — always check.

How do I get call duration?

let startTime;
call.status$.subscribe((status) => {
if (status === "connected") startTime = Date.now();
if (status === "disconnected" && startTime) {
console.log("Lasted:", Math.round((Date.now() - startTime) / 1000), "s");
}
});

Can I record calls?

Recording is controlled by the SignalWire platform, not the browser. startRecording() is on the API surface but not yet implemented in v4 and will throw when called. Recording state configured server-side still surfaces through recording$.

Why “Unimplemented” errors?

Some methods are on the API surface but pending implementation, and others depend on capabilities the server hasn’t granted to this call. Check capabilities$ to gate the UI on what’s actually available.