Troubleshooting
Symptom-first decoder for the most common things that go wrong in production. For code-first lookups (close code 4003, error name MeteredPeerStateError, etc.), see Errors & Codes.
This page assumes you're using the @metered-ca/realtime SDK. Raw-WS users — most symptoms still apply; the diagnostic steps are similar.
Connection won't open
"WebSocket connection failed" / immediate close
What you see in the browser console: WebSocket connection to 'wss://rms.metered.ca/v1?key=...' failed. The SDK throws SignallingConnectError from await peer.join(...) or await client.connect().
Diagnose by the close code (e.closeCode on the thrown error):
| Close code | Most likely cause | Fix |
|---|---|---|
| 4001 Invalid Token | Wrong sk_ signing secret, missing kid header on JWT, or pk_ key revoked | Check dashboard → Keys; re-mint with correct secret + kid: keyId |
| 4002 Token Expired | JWT exp is in the past | Mint shorter-lived tokens + refresh via tokenProvider; the SDK calls it on every reconnect |
| 4003 Channel Not Authorized | JWT's channels claim doesn't include the channel you're trying to join | Mint with channels: ["the-channel-you-want", ...] |
| 4010 Over Concurrent Limit | You hit your plan's maxConcurrentConnections cap | Either upgrade the plan, enable overages, or check for leaked connections you forgot to close() |
| 4012 Account Suspended | Customer-level kill switch (unpaid balance, manual suspend) | Resolve in dashboard billing |
If you see HTTP 401 (not a WS close code), the failure happened before the WebSocket handshake completed. Two common causes:
origin_not_allowedin the response body — your page'sOrigindoesn't match thepk_key'sallowedOriginslist. Add your site's origin (https://app.example.com) to the key's allowed origins on the dashboard.invalid_tokenin the response body — the JWT couldn't be parsed at all (malformed, missingkid, etc.). Origin failures and JWT parse failures both reject at the HTTP layer with no WS close code; check the response body to tell them apart.
If you see HTTP 429, your source IP hit the per-IP connect rate limit (default 60 attempts / 60s). Throttle on the client side or wait — this resolves itself.
Connection opens, then closes after seconds
Closes within 5-10 seconds with code 1006 (abnormal): the server's inactivity watchdog fired or the client's did. Causes:
- JWT very close to expiry at connect time — the server force-closes ~250ms before
exp. Mint with a longer expiry (1 hour recommended). - Proxy / corporate firewall strips WebSocket frames. Diagnose by trying from a different network (mobile hotspot).
- Browser tab backgrounded — some browsers throttle WS in inactive tabs. The SDK reconnects automatically once the tab is active again.
tokenProvider keeps failing
Symptom: token-provider-error event fires on SignallingClient / MeteredPeer. SDK keeps retrying with the JWT mint endpoint and never gets a successful welcome.
Diagnose:
- Check the endpoint your
tokenProvidercalls. Does it return a JWT under any circumstance? - Inspect the error payload on the
token-provider-errorevent —e.err.messagecarries the failure detail. - Verify the JWT's claims using a JWT debugger (jwt.io). Common mistakes: missing
kidin the JWT header,subnot a string,expin seconds (not milliseconds).
client.on("token-provider-error", ({ consecutiveFailures, err }) => {
if (consecutiveFailures >= 3) {
showLoginPrompt("Your session expired — please log in again");
}
});
WebRTC media won't connect (video/audio black)
Peers join the channel but video stays black
Symptom: peer-joined fires for each remote, state-change events fire, but track / stream-added never lands (or it lands and the <video> element stays black).
Diagnose by checking remote.state:
state | Diagnosis |
|---|---|
Stuck on "connecting" for >10s | ICE never completed — either no TURN available, or all candidates failed. See "no TURN" path below. |
Briefly "connected" then "failed" | Network change during call (Wi-Fi → cellular). The SDK's ICE-restart ladder retries 9 times over ~121s; should recover. |
"connected" but black video | Media transport succeeded, codec problem. Check the local MediaStream you got from getUserMedia actually has video tracks: localStream.getVideoTracks().length should be ≥ 1. (The SDK doesn't expose a getter for attached streams — hold the MediaStream reference yourself when you call peer.addStream(localStream).) |
"No TURN available" path
If remote.state is stuck "connecting" for >10s, your peers can't reach each other via direct ICE candidates AND TURN isn't working. Check:
- Is auto-inject enabled? Dashboard → Realtime Messaging → Keys → the relevant key's "TURN" column should show "On". Default is on for new keys.
- Does the app have a TURN service? Dashboard → TURN must show an active service (any tier, including free). Without TURN, the signalling server can't inject creds and you're stuck on STUN-only / host candidates.
- Inspect the
connectedevent payload. OnSignallingClient:Ifclient.on("connected", ({ iceServers }) => console.log(iceServers));iceServersisundefinedor empty, the injection isn't happening — confirm #1 + #2. - Check your network firewall. Some corporate networks block TURN UDP. Try
transport=tcpURLs or theturns:port 443 entries that ship in the auto-injected bundle (the SDK applies all of them, but a network may only let one through).
Customer-supplied iceServers being silently ignored
If you put metadata.iceServers in your JWT and the SDK still seems to be using auto-injection (or nothing):
- The presence check in the signalling server is
"iceServers" in metadata— any value, including primitives or empty arrays, suppresses auto-injection. - The browser-side validator in the SDK rejects entries with unrecognized URL schemes (
stun:/stuns:/turn:/turns:only) or oversized fields (URLs > 512 chars, username/credential > 1024 chars). - If any entry fails validation, the whole array is dropped (fail-closed). Diagnose by enabling debug logging:Look for
new MeteredPeer({ logger: console, ... });"extractIceServersFromMetadata"rejections in the output.
Messages aren't arriving
Published a message but no one receives it
Symptom: peer.send(...) / client.publish(...) resolves without error, but other subscribers never see the message.
Diagnose:
- Are the other peers actually subscribed? Subscription is per-connection — if they reconnected, the SDK re-subscribes via
autoResubscribe(default on). If they usedautoResubscribe: false, they need to re-subscribe manually after reconnect. - Are you broadcasting on the right channel?
peer.send(data)broadcasts to your CURRENT channel (the one passed topeer.join).client.publish(channel, data)lets you target any channel — verify the channel string matches what subscribers expect. - Is the message reaching the server? If
publish()rejected, check the rejection reason —channel_not_authorizedmeans your key doesn't have publish permission to this channel. - Are subscribers on the same Metered app? Cross-app messages don't route. Check the
appIdin your JWTs / keys.
Receiving stops after a reconnect
Symptom: messages flow normally, then after a reconnect, nothing arrives.
Most likely: autoResubscribe: false was set on SignallingClient and the subscription wasn't re-issued after reconnect. The SDK's default autoResubscribe: true should handle this — verify your constructor options.
If you're using MeteredPeer, this is handled automatically — the channel passed to peer.join() survives reconnect.
error event with code: "over_message_quota"
You exhausted your plan's maxMessagesPerPeriod for the current billing window. With overages off, every subsequent publish/send rejects with this error until the period rolls over. With overages on but balance empty, same outcome. The connection STAYS OPEN — only message-emitting actions are blocked, you can still receive.
Fix: enable overages or wait for the period boundary. See Authentication → Rate Limits.
Performance issues
Connect is slow (>2 seconds)
The first connect for a key on a cold cache is slower (~200-500ms) than subsequent ones (cached at <10ms). If you're seeing multi-second connects:
- Cold-start JWT signing on your backend. Profile the mint endpoint independently.
tokenProviderTimeoutMstiming out and retrying. Default is 10s — if your mint endpoint is consistently slow, raise it. Or speed up the mint.- Per-IP rate limit causing retries. Look for HTTP 429 in your network tab.
Publish/send latency higher than expected
peer.send and client.publish are server-routed, not P2P. Typical p99 ~50ms within a region, ~100-200ms cross-region. If you see higher:
- Sender is hitting the per-connection token bucket (100 msg/sec sustained). Server is buffering / dropping. Throttle on the client.
- Server-side queue backpressure during a customer surge. Rare; usually self-resolves.
For latency-sensitive paths (game state, cursor sync), open a P2P RTCDataChannel via the escape hatch — see Data Channels & Low Latency.
Reconnect issues
Stuck in "reconnecting" indefinitely
Symptom: state enters "reconnecting" and never returns to "connected". The SDK keeps trying.
Diagnose by listening to disconnected events — each reconnect attempt produces one. The code field tells you what's keeping you out:
- 4001 / 4003 / 4012 / 4020 — these are terminal; the SDK SHOULDN'T be retrying them. If it is, that's an SDK bug — file an issue.
- 4002 —
tokenProvideris returning a stale JWT. Check that your mint endpoint actually issues fresh tokens on each call. - 4010 — your plan's concurrent connection cap is full. The SDK uses a 30s+ backoff floor for this code so it doesn't hammer.
- 1006 / no code — network issue. The SDK should recover when the network comes back. If you're on a flaky link, expect periodic reconnects.
See Reconnect Best Practices — Stuck in reconnecting for the full diagnostic flow.
RemotePeer reference still exists but remote.pc is dead
After a Realtime Messaging service reconnect, the SDK preserves your RemotePeer references but silently swaps the underlying RTCPeerConnection. If you cached remote.pc somewhere (a Map, React state, etc.), that cache is now stale.
Fix: re-read remote.pc every time you need it, or wire it to the state-change → "connected" event:
remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
const dc = remote.pc.createDataChannel("data"); // always fresh
});
See Data Channels & Low Latency for the canonical reconcile-safe pattern.
React Native
Using the SDK on React Native? See the React Native guide for setup — these are the failures that trip people up most.
TextEncoder is not defined — throws on the first send / sendTo
@metered-ca/realtime measures payload sizes without relying on a global
TextEncoder (absent on some React Native / Hermes runtimes), so this
shouldn't occur on a current install. If you hit it on an outdated build,
upgrade:
npm install @metered-ca/realtime@latest
Crash on launch, or RTCPeerConnection is not a constructor
You're running in Expo Go, which can't load react-native-webrtc's
native module. Switch to a dev build
with the @config-plugins/react-native-webrtc plugin, or use bare React
Native. See the guide.
Events never fire / addEventListener is not a function
An old react-native-webrtc that predates addEventListener support on
its WebRTC objects. Pin a current major (≥ 100).
Video is black, or no permission prompt appears
- Request camera/mic at runtime before
getUserMedia(Android 6+ / iOS), and confirm theInfo.plist/AndroidManifest.xmlentries from the guide are present. - Render remote media with
<RTCView streamURL={stream.toURL()} />, not<video>, and re-read.toURL()on everystream-added.
stream-removed doesn't fire when a remote track ends
In SDK ≥ 1.1, detection rides two signals: the stream's removetrack
event (sender-side removeStream / removeTrack — the common case) and
the track's ended event (stopped tracks, closed connections). Both
require react-native-webrtc to emit standard MediaStream / track events —
pin a current major (≥ 100). If you still don't see it, drive teardown
off peer-left / state-change as a fallback.
Where to look next
| If you've tried this page and… | Read |
|---|---|
| Want code-first lookup of an error class or close code | Errors & Codes |
| Want the wire-level details on what each error means | Protocol → Close Codes · Error Codes |
| Want production reconnect patterns | Reconnect Best Practices |
| Want plan limits + abuse bounds | Rate Limits & Quotas |
| Need to file a bug or get help | Open a support ticket |