Skip to main content

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 codeMost likely causeFix
4001 Invalid TokenWrong sk_ signing secret, missing kid header on JWT, or pk_ key revokedCheck dashboard → Keys; re-mint with correct secret + kid: keyId
4002 Token ExpiredJWT exp is in the pastMint shorter-lived tokens + refresh via tokenProvider; the SDK calls it on every reconnect
4003 Channel Not AuthorizedJWT's channels claim doesn't include the channel you're trying to joinMint with channels: ["the-channel-you-want", ...]
4010 Over Concurrent LimitYou hit your plan's maxConcurrentConnections capEither upgrade the plan, enable overages, or check for leaked connections you forgot to close()
4012 Account SuspendedCustomer-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_allowed in the response body — your page's Origin doesn't match the pk_ key's allowedOrigins list. Add your site's origin (https://app.example.com) to the key's allowed origins on the dashboard.
  • invalid_token in the response body — the JWT couldn't be parsed at all (malformed, missing kid, 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:

  1. Check the endpoint your tokenProvider calls. Does it return a JWT under any circumstance?
  2. Inspect the error payload on the token-provider-error event — e.err.message carries the failure detail.
  3. Verify the JWT's claims using a JWT debugger (jwt.io). Common mistakes: missing kid in the JWT header, sub not a string, exp in 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:

stateDiagnosis
Stuck on "connecting" for >10sICE 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 videoMedia 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:

  1. Is auto-inject enabled? Dashboard → Realtime Messaging → Keys → the relevant key's "TURN" column should show "On". Default is on for new keys.
  2. 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.
  3. Inspect the connected event payload. On SignallingClient:
    client.on("connected", ({ iceServers }) => console.log(iceServers));
    If iceServers is undefined or empty, the injection isn't happening — confirm #1 + #2.
  4. Check your network firewall. Some corporate networks block TURN UDP. Try transport=tcp URLs or the turns: 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:
    new MeteredPeer({ logger: console, ... });
    Look for "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:

  1. Are the other peers actually subscribed? Subscription is per-connection — if they reconnected, the SDK re-subscribes via autoResubscribe (default on). If they used autoResubscribe: false, they need to re-subscribe manually after reconnect.
  2. Are you broadcasting on the right channel? peer.send(data) broadcasts to your CURRENT channel (the one passed to peer.join). client.publish(channel, data) lets you target any channel — verify the channel string matches what subscribers expect.
  3. Is the message reaching the server? If publish() rejected, check the rejection reason — channel_not_authorized means your key doesn't have publish permission to this channel.
  4. Are subscribers on the same Metered app? Cross-app messages don't route. Check the appId in 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.
  • tokenProviderTimeoutMs timing 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.
  • 4002tokenProvider is 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 the Info.plist / AndroidManifest.xml entries from the guide are present.
  • Render remote media with <RTCView streamURL={stream.toURL()} />, not <video>, and re-read .toURL() on every stream-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 codeErrors & Codes
Want the wire-level details on what each error meansProtocol → Close Codes · Error Codes
Want production reconnect patternsReconnect Best Practices
Want plan limits + abuse boundsRate Limits & Quotas
Need to file a bug or get helpOpen a support ticket