Skip to content

Operate

Troubleshooting Video, Audio and Connection Problems

Tell device, network and service problems apart in VoltKit RTC: soft or frozen video, echo, silent audio, blocked connections and camera errors, with fixes.

Most problems with live video and audio start on a device or on the network between a person and VoltKit: a phone that cannot encode fast enough, an upload that is too slow, a dark room, an office firewall. Each one leaves signs you can check, and each has a fix. This page shows how to tell them apart. It is written for developers, and for the people who answer their users.

VoltKit forwards the video a host sends without re-encoding it. What viewers see is what the host's device encoded, in the largest copy their own connection can carry. When the cause is on our side, it usually shows up for many people at once rather than for one, and we want to hear about it: see Still stuck?.

Start here: device, network or service?

Ask who sees the problem. That alone usually points at the cause.

What you seeLook atUsually
One viewer's picture is blurry or stutters; everyone else's is fineThat viewerTheir download, Wi-Fi or a busy device. They get a smaller copy until their connection recovers
Every viewer's picture is blurry, but the host's own preview is sharpThe host's getVideoStats()cpu: the host's device is overloaded. bandwidth: the host's upload is too slow
The host's own preview is blurry tooThe camera and the roomThe camera's size, the light or the app's own drawing. This happens before VoltKit is involved
Everyone's picture freezes at the same momentThe hostThe host's upload dropped, their app went to the background, or their phone is overloaded. If viewers receive streamInterrupted, a media server failed and the picture comes back by itself
No sound until someone taps the pageThat viewer's browserAutoplay rules
The host cannot start their camera or microphoneThe error's namePermission, or another app using the camera. If navigator.mediaDevices is undefined, the page is not on HTTPS
Chat works and track events arrive, but the video stays black and silentThat viewer's network, or the host's if every viewer sees itThe media ports are blocked. Chat travels on a different connection from audio and video
One viewer gets no video from hosts on phones, and their app's error event has the code cannot-consumeThat viewer's browserIt cannot play H.264, which phones usually send
People on one office or school network cannot connect; others canThat networkA firewall or VPN
Every room fails at once, for people on different networksVoltKit, or your token endpointErrors such as no-capacity or node-error come from our side. Tell us

The host's own preview shows the camera before it is encoded, so it is the best the picture can look. If the preview is soft, the cause comes before the encoder; if only viewers see it soft, it happened during encoding or on the way.

Check what the host sends

From version 0.4.0, getVideoStats() describes the video being published: the largest copy in the top-level fields, and every copy in layers.

const stats = await client.getVideoStats();   // null when no camera is published
if (stats) {
  console.log(stats.width, stats.height, stats.fps, stats.bitrate, stats.codec);
  console.log(stats.qualityLimitation);         // "cpu", "bandwidth", "other" or "none"
  console.log(stats.availableOutgoingBitrate);  // the browser's estimate of the upload, bits per second
  for (const layer of stats.layers) console.log(layer.rid, layer.width, layer.height, layer.active);
}

bitrate is measured since the previous call, so the first call returns 0. Call it every two seconds or so. A copy with active: false is not being sent.

The displayed Mbps is video sent on the largest active copy, not connection speed or total upload across all copies. Resolution and bitrate need not move together: the browser can reduce the frame size for encoding load while still using the configured bitrate budget. A codec label such as H264 does not establish whether hardware encoding is in use.

The RTC refinement SDK update (currently unreleased) also exposes optional source.width, source.height, source.fps, encoderImplementation and powerEfficientEncoder when the browser supplies them. source describes input to the sender, which can be a canvas; top-level dimensions and FPS describe encoded output. Do not substitute the requested camera FPS for a measured rate. These fields follow the WebRTC statistics definitions.

In the demo, Stream stats separates selected quality, direct-camera/canvas source and encoded output. Input and encoder details appear with the updated SDK on supported browsers. With effects off, uncropped camera tracks bypass the canvas even if resizing is needed; the SDK applies the selected quality ceiling.

qualityLimitationMeaningGo to
cpuThe browser reports an encoding limit; it may reduce picture size or FPSEncoding load
bandwidthThe upload cannot carry the video, so the browser sends lessSlow upload
otherSomething else in the browser is holding it backRare. Include it when you contact support
noneNothing is holding the encoder backIf the picture is still soft: the camera, the light, the app's drawing

Browsers that do not report a reason leave qualityLimitation out, and videoLimited then stays quiet.

Be told when it happens

Rather than polling, listen for videoLimited (0.4.0). It fires when the reason changes and has held for about four seconds, so brief spikes stay quiet, and fires with none when it clears:

client.on("videoLimited", ({ reason, width, height, fps }) => {
  console.log(`limited by ${reason}: ${width}×${height} at ${fps} fps`);
});

The SDK samples every two seconds while a camera is published and a listener is registered, or while monitoring an opt-in 60 fps stream. Show your users what is happening turns this into a notice for the host.

Check what a viewer receives

On a viewer, getReceiveStats() (0.4.0) takes the producerId from the track event:

const stats = await client.getReceiveStats(producerId);   // null if that video is not being received
if (stats) console.log(stats.width, stats.height, stats.fps, stats.bitrate, stats.packetsLost, stats.freezeCount);
  • Smaller than the host sends: this viewer gets a smaller copy, because of their connection.
  • packetsLost climbing between calls: their network is losing packets.
  • freezeCount going up: the picture froze, from loss or a device too busy to decode.
  • Low fps with no loss: the media server may be sending this viewer fewer frames per second to fit their connection (their bitrate is then low too), their device may be too busy to decode, or the host is sending fewer frames. Compare with the host's getVideoStats().

Demo co-host performance and delay

The live demo offers a full-screen gallery, a pinned focus view, a screen-sharing view and separate chat/people panels. Layout changes keep existing video tracks attached. Starting another camera adds capture, encoding and decoding work, especially on older phones.

Studio → camera quality changes what you publish. Quality → Viewing quality changes what you receive. Lowering viewing quality does not lower your camera. For a busy phone, try 480p camera quality, 30 fps (60 fps off), Original and no effects; then lower viewing quality if receiving video is also slow. Camera quality, screen sharing and recording quality are separate controls.

Open Stream stats (the information button beneath the grid) to see stream health and remote frame timing. The demo uses the browser's video-frame metadata:

  • Est. frame age is expected display time minus estimated capture time, available only when the browser supplies a valid remote capture timestamp.
  • Playback is expected display time minus receipt of that frame's last packet. It describes the receiving side; it excludes capture, encoding and the network journey.
  • Frame timing unavailable means the browser did not supply a usable capture timestamp. A playback value can still appear. Neither playback time nor packet jitter should be presented as total call latency.

A smooth picture can still arrive late; a low frame rate can look laggy even with a short delay. Compare sender/receiver fps, loss and freezes before changing quality or server settings. Device load, connection conditions and distance to the media server affect the result. The demo does not promise a fixed delay, and a local browser test does not measure a phone on a mobile network.

Browser tools

  • Chrome and Edge: open chrome://webrtc-internals (edge://webrtc-internals) in another tab, before or during the session. For each connection, outbound-rtp shows each copy's frameWidth, framesPerSecond and qualityLimitationReason; inbound-rtp shows packetsLost, jitter and freezeCount; the active candidate pair shows whether media runs over UDP, TCP or the relay. The page can also download a dump for support.
  • Firefox: open about:webrtc.
  • Safari: turn on WebRTC logging in Web Inspector's settings and watch the Console.

Blurry or soft video

While the picture is blurry, call getVideoStats() on the host. On SDK 0.3.0, read qualityLimitationReason in the host's chrome://webrtc-internals instead.

Device overloaded

Signs. qualityLimitation is cpu, or videoLimited fires with cpu. The picture gets smaller or the frame rate drops, from the start or after a while as the phone heats up, and more with effects on.

Why. cpu is the browser's encoding-limit report, not a diagnosis of the entire phone. Capture, canvas processing, encoder implementation and concurrent work can all matter. Compare sender-input FPS with encoded FPS before attributing the problem to the device.

Switching to another app can slow or pause browser capture. The RTC refinement SDK update excludes hidden-page samples from its limitation warnings and 60→30 fps fallback, discards samples crossing a visibility change and allows foreground warm-up. It cannot override the browser's background capture policy or guarantee 30/60 fps on every camera. A selected 1080p setting is a quality ceiling; it does not guarantee that the camera or encoder delivers that size continuously.

  • Each copy of the video is a separate encode. Three copies are three encoders running at once.
  • Many phones encode H.264 in hardware but VP8 in software, on the main processor.
  • Effects drawn on a canvas (filters, beauty, particles) run on every frame, before the encoder starts.
  • Phones slow their processors down when they get hot: a long session, direct sun, a thick case, or charging while live.
  • Battery saver modes slow the processor down too.
  • Other apps and browser tabs compete for the same processor.

In our tests, with the browser's processor throttled to match a weak phone, Chrome shrank a portrait picture to 360×640 and, asked for three copies, stopped sending the largest one: viewers got 180×320 at about 17 fps. Asked for two, it kept sending the largest copy, which never fell below 270×480.

Fix.

  1. Use @voltkit-rtc/client 0.4.0 or later. On phones and tablets it sends two copies, and H.264 in hardware when the device has it (codec: "auto", the default). Force "vp8" only if some viewers' browsers cannot play H.264.
  2. Send fewer copies with layers: 2, or 1. With one copy every viewer receives the same picture, including viewers whose connection cannot carry it.
  3. Send 480p from older or budget phones.
  4. Use fewer or lighter effects, and give hosts a way to turn them off. Suggest it when videoLimited reports cpu.
  5. If you draw on a canvas, make it the size you send, not larger.
  6. When no effect is on, publish the camera track itself and skip the canvas. replaceVideoTrack() swaps between the two while live.
  7. Keep the phone cool and charged: out of direct sun, battery saver off, other apps closed.
  8. Decide what to give up. degradationPreference: "maintain-resolution" keeps the picture size and lowers the frame rate when the device is busy; "maintain-framerate" keeps motion smooth and lowers the size; "balanced" gives up some of each. When the option is not set, Chrome takes it from track.contentHint: "detail" keeps the size, "motion" keeps the frame rate. For faces and products, keeping the size usually looks better, but only when the device encodes in hardware: a phone encoding in software can drop to a few frames per second, so there keep the frame rate and use 480p. Browsers that do not support the option ignore it.
await client.publish(media, constraints, {
  quality: "720p",
  layers: 2,                                    // phones and tablets already default to 2
  codec: "auto",                                // H.264 in hardware where the device has it
  degradationPreference: "maintain-resolution", // when busy, lower the frame rate, not the size
});

// No effect on: send the camera. An effect on: send the canvas, drawn at the camera's size.
await client.replaceVideoTrack(effectsOn ? canvasTrack : cameraTrack);

A track of the same size swaps without interrupting viewers. A track of a different size can restart the video, and viewers see a short pause.

The Flutter SDK already sends two copies, prefers hardware H.264 and keeps the size under load. To lighten it further, send 480p or one copy, and log onPublishWarning, which says when a codec or setting could not be applied:

client.onPublishWarning.listen(debugPrint);
await client.publish(video: const VideoPublishOptions(quality: VideoQuality.p480));

Slow upload

Signs. qualityLimitation is bandwidth, or videoLimited fires with bandwidth. availableOutgoingBitrate is below what the quality needs. The picture softens and recovers as the connection changes, and is worse on mobile data, far from the router, or while something else on the network uploads.

Why. The host uploads every copy at once, and most home and mobile connections upload far slower than they download. A speed test's download figure says little here; look at its upload figure.

QualityThe host sends aboutAim for a steady upload of
1080p308.4 Mbps (8 from a phone), before audio11 Mbps or more
1080p6011.4 Mbps (11 from a phone), before audio15 Mbps or more
720p304 Mbps (3.8 from a phone), before audio5 Mbps or more
480p1.4 Mbps2 Mbps or more

Common causes:

  • Wi-Fi: distance and walls between the device and the router, or the crowded 2.4 GHz band.
  • Mobile data: a weak signal, or a busy cell in a crowded place.
  • Other uploads on the same network: photo backups, file sync, another video call.
  • VPNs: they add overhead and can send traffic a long way round.

Fix.

  • Test the upload from where the host streams.
  • Move closer to the router, use 5 GHz Wi-Fi, or plug in a cable.
  • Pause backups and large uploads while live.
  • Send 480p on mobile data or a weak connection.
  • Switch the VPN off while streaming.

When the upload drops, the browser sends less and viewers move to a smaller copy. They keep watching a softer picture rather than a frozen one.

The camera captures less than you asked for

Signs. getVideoStats() shows a smaller picture than the quality, with qualityLimitation at none.

Why.

  • video: true lets the browser choose the size, often 640×480.
  • Many laptop webcams top out at 720p, and some deliver less, or a lower frame rate, in dim light.
  • A phone's front camera is usually less sharp than its back camera.
  • Virtual cameras (screen recorders, streaming tools) send whatever size they are set to.
  • The SDK scales a picture down to the quality, never up.

Fix. Ask for a size, then check what you got:

const media = await navigator.mediaDevices.getUserMedia({
  audio: true,
  video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30 } },
});
const [camera] = media.getVideoTracks();
console.log(camera.getSettings());   // { width, height, frameRate, ... } as captured

ideal values never fail. exact, min and max values make getUserMedia throw OverconstrainedError on a camera that cannot meet them.

Low light and backlight

Signs. The preview is grainy or dark, and the picture smears when the person moves.

Why. In dim light the camera raises its gain, which adds grain. The encoder spends its bits on the grain and everything else gets softer. Many cameras also lower their frame rate in dim light, to expose each frame for longer: in our tests a phone that sent 25–30 fps in good light sent 14 fps in a dim room. source.fps in getVideoStats() (where the browser supplies it) shows the rate the camera delivers, and the live demo says so when it stays low. A bright window behind someone leaves their face dark.

Fix. Light the person from the front (a window or a lamp facing them), switch the room lights on, and keep bright windows out of the background. On phones, wipe the lens.

The app's own processing and display

Signs. The video looks soft in your app, but getVideoStats() on the host and getReceiveStats() on the viewer report the full size.

  • Canvas scaling. Drawing the camera onto a canvas of a different size rescales every frame. Draw at the size you send, and set context.imageSmoothingQuality = "high" where you do scale.
  • Effects that add detail. Particles, grain and strong filters are harder to compress, so at the same bitrate the rest of the picture gets softer. Glow and blur effects soften the picture on purpose.
  • object-fit: cover. It crops the video to fill its element, then enlarges what is left: a landscape stream in a tall, phone-shaped element shows a small middle part, blown up. Use contain, or give the element the video's shape.
  • Elements bigger than the picture. A 640×360 copy in a full-screen element is enlarged several times over. Compare what was decoded with what is shown:
video.addEventListener("resize", () => {
  const shown = Math.round(video.clientWidth * devicePixelRatio);
  console.log(`decoded ${video.videoWidth}px wide, shown ${shown}px wide`);
});

One viewer gets a smaller copy

Signs. One viewer's getReceiveStats() reports a smaller size than other viewers get.

Why. This is how it is meant to work. The host sends several copies, and the media server gives each viewer the largest copy their connection can carry, sometimes at a lower frame rate, moving them up again as it improves. Without it, a viewer on a weak connection would freeze instead. A viewer needs about 3.1 Mbps of download for the full 720p30 picture (see Recommended devices and connections).

Fix. On the viewer's side: closer to the router, 5 GHz Wi-Fi, no other large downloads. A small tile can ask for a smaller copy with setPreferredLayers (in the JavaScript SDK from 0.4.0, and in Flutter), which saves that viewer's data; see Small tiles.

The first second or two

When a stream starts, the host's encoder begins below its target and climbs as the browser measures the upload, so the first second or two can look softer. At 720p and 480p, viewers start on the largest copy when their connection allows it; at 1080p they can start one copy lower and move up once their connection has been measured. A viewer still soft after several seconds has one of the causes above.

The project allows a lower quality

Asking for more than the project allows is not an error: the SDK sends the highest quality the project allows. Check client.videoPolicy.maxQuality after connect(). Every plan includes 1080p by default. A lower joined policy is an explicit operator restriction; ask support to review it. Buying a plan does not change that restriction. See Video quality.

Stutter, freezes and delay

Lost packets and Wi-Fi interference

Signs. Short freezes, blocky patches that clear up, audio dropouts. On the viewer, packetsLost and freezeCount climb.

Why. Video arrives as a stream of small packets. When some are lost, the picture waits for a repair or a fresh full frame. Wi-Fi loses packets far from the router, behind walls and on crowded channels; mobile networks lose them when the signal is weak or the cell is busy.

Fix. Closer to the router, 5 GHz Wi-Fi, a cable for hosts. A viewer whose connection struggles is moved to a smaller copy, which needs fewer packets.

A busy device on either end

Signs. The frame rate drops but no packets are lost. On the host, qualityLimitation is cpu; on a viewer, fps falls while packetsLost stays flat.

Why. A busy host encodes fewer frames, for everyone (Device overloaded). A viewer decodes every video on screen: a phone showing nine tiles decodes nine streams. A viewer on a weak connection can show the same signs without being busy: to fit their connection, the media server may send them fewer frames per second as well as a smaller copy, and their received bitrate is low.

Fix. For hosts, the fixes under Device overloaded. For viewers, show fewer video tiles at once on phones, and ask for smaller copies for small tiles with setPreferredLayers (JavaScript from 0.4.0, and Flutter).

Background tabs and locked screens

Signs. The host's video freezes for everyone while the host switches apps, opens another tab or locks the phone.

Why.

  • Browsers throttle hidden pages: requestAnimationFrame stops and timers can be slowed to about once a second, so a canvas pipeline driven by them stops producing new frames.
  • Mobile browsers pause the camera while the browser is in the background or the screen is locked, and some leave a <video> element paused when the page comes back.

Fix. Ask hosts to keep the app in front while live. If your pipeline draws from a <video> element, start it again when the page is visible:

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible" && cameraVideo.paused) void cameraVideo.play();
});

If the camera track itself has ended (readyState is "ended"), capture again and publish the new track with replaceVideoTrack().

UDP is blocked

Signs. Video works, but delay builds up and stalls last longer. In webrtc-internals, the active candidate pair uses tcp, or a relay candidate.

Why. Media normally travels over UDP. When a network blocks UDP, the browser falls back to TCP, either straight to the media server or through VoltKit's relay (TURN). That works, but TCP holds everything back until a lost packet has been sent again, so a loss becomes a stall and delay builds up.

Fix. Allow UDP to the media ports: see What the network must allow.

Distance adds delay, not stutter

Distance to VoltKit's servers adds delay: people hear each other a little later. It does not make the picture stutter; stutter means lost packets or a busy device. A VPN can send traffic a long way round and add delay for no benefit.

What the SDK does by itself

  • Reconnects. When the connection drops, the SDK retries with growing gaps between attempts, rejoins, and publishes the camera and microphone again. If it cannot reach VoltKit eight times in a row, it stops and emits closed with gave-up; from SDK 0.4.0 it also stops after eight rejoins in a row that VoltKit turns away for now, such as when every media server is full. It stops at once, also with gave-up, when retrying cannot help: the room has ended, the token is not valid, or the account cannot be used.
  • Follows the room when a server fails. If a media server fails, the room moves to another one. Clients that have to move receive mustReconnect and reconnect on their own. Viewers who can stay receive streamInterrupted: the host is reconnecting, and the picture comes back by itself.
  • Keeps its token fresh, so a reconnect late in a long session still works.

None of these need code from you, but your users should see what is going on:

client.on("disconnected", () => banner.show("Reconnecting…"));
client.on("joined", () => banner.hide());
client.on("streamInterrupted", () => banner.show("The host is reconnecting…"));
client.on("track", () => banner.hide());
client.on("closed", (reason) => {
  // "disconnected" fires first for some of these, so always replace or hide the banner.
  if (reason === "gave-up") banner.show("Connection lost. Check your network and join again.");
  else if (reason === "replaced") banner.show("You joined from another tab or device.");
  else banner.hide();   // "left", "kicked", "room-closed": show your own end screen
});

When a host reconnects, their video arrives on viewers as a new track event. Attach each new track as it comes, not only the first. The Flutter SDK behaves the same way, with onStreamInterrupted and onClosed (ClosedReason.gaveUp).

Audio problems

Echo

Signs. People hear themselves a moment after they speak.

Why. Someone's speakers are playing the room and their microphone picks it up. The browser's echo cancellation removes most of it, but not loud speakers, a microphone right next to a speaker, or two devices joined from the same physical room. The person who hears the echo is usually not the cause: it is the other person's speakers.

Fix.

  • Headphones, for whoever is on speakers.
  • Keep echo cancellation on. It is the browser's default; do not switch it off to make voices sound fuller.
  • One device per physical room, or every other device muted with its sound off.
  • Play music with publishMusic, not through speakers into the microphone.
const media = await navigator.mediaDevices.getUserMedia({
  audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
  video: { width: 1280, height: 720 },
});

Bluetooth headsets sound like a phone call

Why. While a Bluetooth headset's microphone is in use, most headsets switch to their hands-free profile: mono, call-quality sound, in both directions. Voices and music sound like a phone call.

Fix. Use the device's own microphone with Bluetooth headphones, or a wired headset. In a browser, let the user pick the microphone: list them with navigator.mediaDevices.enumerateDevices() and pass the chosen deviceId to getUserMedia.

No sound until the viewer taps

Signs. A viewer who opened a link and waited sees the video but hears nothing.

Why. Browsers often refuse to play sound from a page until the person has interacted with it. Muted video may play while the audio waits for a tap or click.

Fix.

  • Start playback from a click, such as a Join button that calls connect().
  • When play() is refused, show Tap to unmute and call play() again from that tap.
  • On iPhone, give video elements playsinline, or Safari may play them full screen.
client.on("track", ({ kind, track }) => {
  if (kind !== "audio") return;
  const audio = new Audio();
  audio.srcObject = new MediaStream([track]);
  audio.play().catch(() => showTapToUnmute(() => audio.play()));
});

Robotic or choppy audio

Why. Lost packets on weak Wi-Fi or mobile signal, a device too busy to keep up, or a Bluetooth headset at the edge of its range. The SDK sends voice with error correction, which covers small losses; heavy loss still sounds robotic.

Fix. The fixes for lost packets and a busy device, and keep Bluetooth devices close.

A microphone that is too quiet or too loud

  • The operating system's input level caps what the browser receives. Check it in the system's sound settings.
  • Automatic gain control (autoGainControl, on by default) evens levels out. Switching it off makes quiet speakers quieter.
  • Laptop microphones far from the speaker's mouth pick up the room more than the voice.
  • A headset with a hardware mute switch sends silence while the app shows the microphone on.

Cannot connect

What the network must allow

TrafficProtocol and port
Your app, and your backend that mints tokensHTTPS, usually port 443
The realtime connection (wsUrl): rooms and chatSecure WebSocket (WSS), TCP port 443
Media to and from VoltKit's media serversUDP, ports 44444 and up; TCP on the same ports when UDP is blocked
VoltKit's relay (TURN), when the media servers cannot be reached directlyUDP and TCP, port 3478

Each media server listens on a short run of ports starting at 44444. The relay's credentials come with each join, and the SDKs use the relay on their own. To allowlist VoltKit exactly, ask support for the current addresses and ports.

A network that allows only HTTPS on port 443 cannot carry VoltKit's audio and video today. Chat still works there, because it rides the realtime connection.

When connect() rejects with websocket failed to open, the device could not reach VoltKit at all: no internet, a Wi-Fi login page (hotels, airports) that has not been accepted yet, or a proxy that blocks WebSockets.

Office networks and VPNs

Signs. Someone connects from home but not from the office; or chat works but no video arrives; or joining hangs.

Why. Office and school firewalls often block UDP and unusual ports, and some proxies break WebSockets. VPNs can block UDP or route everything through a distant server.

Fix. Ask the network's administrator to allow the traffic in the table above. Try mobile data to confirm the network is the cause. Switch the VPN off, or exclude the app from it. Some privacy extensions limit or disable WebRTC as well, so if only one browser fails, try a private window with extensions off.

In-app browsers

Links opened inside Instagram, Facebook and similar apps load in the app's own browser, which often cannot use the camera or microphone. Ask the user to open the page in their browser from the app's menu. Both apps name themselves in the user agent:

const inAppBrowser = /Instagram|FBAN|FBAV/.test(navigator.userAgent);
if (inAppBrowser) showOpenInBrowserHint();

Pages inside an iframe

A page in an iframe from another origin needs the parent page's permission to use the camera and microphone, or getUserMedia fails with NotAllowedError. Add autoplay if the frame plays sound:

<iframe src="https://live.example.com/room" allow="camera; microphone; autoplay"></iframe>

HTTPS is required for the camera

Browsers give pages the camera and microphone only over HTTPS, or on http://localhost while you develop. On plain HTTP, navigator.mediaDevices is undefined. Testing from a phone against your laptop's local network address fails for the same reason: use an HTTPS tunnel or a deployed preview.

Switching between Wi-Fi and mobile data

When a phone moves between Wi-Fi and mobile data, its connection to VoltKit drops. The SDK reconnects over the new network and publishes again after a short pause. Viewers see the host's video stop, then arrive again as a new track event.

A viewer's browser cannot play H.264

Signs. One viewer sees hosts who stream from computers but gets no video from hosts on phones, and their app's error event carries an RpcError with the code cannot-consume.

Why. Phones usually send H.264, because they encode it in hardware: the browser SDK does so from 0.4.0 (codec: "auto"), and the Flutter, Android and iOS SDKs do by default. Computers on the browser SDK send VP8. Most browsers play both, but a browser built without H.264 cannot receive it.

Fix. Ask the viewer to try another browser. If many of your viewers use such a browser, make every host send VP8: codec: "vp8" in the browser SDK, VideoPublishOptions(codecMimeType: 'video/VP8') in Flutter. VP8 costs phones more to encode, so send 480p from phones that then struggle.

import { RpcError } from "@voltkit-rtc/client";

client.on("error", (err) => {
  if (err instanceof RpcError && err.code === "cannot-consume") showTryAnotherBrowserHint();
});

Camera and microphone errors

getUserMedia reports these as a DOMException, by name. They come from the browser and the device, before VoltKit is involved.

nameWhat happenedTell the user
NotAllowedErrorThe user, the browser or the operating system refused access. Also an iframe without allow="camera; microphone""Allow camera and microphone access for this site in your browser's settings, then try again." On a Mac, the browser also needs access in System Settings, under Privacy and Security
NotFoundErrorNo camera or microphone, or none that matches the request"No camera or microphone was found. Connect one, or join to watch."
NotReadableErrorThe device exists but could not start: another app is using it, or the driver failed"Your camera is in use by another app. Close it, then try again."
OverconstrainedErrorThe camera cannot meet an exact, min or max requirement; error.constraint names itRetry with ideal values. "Your camera cannot use that quality. Try a lower one."
const messages: Record<string, string> = {
  NotAllowedError: "Allow camera and microphone access for this site, then try again.",
  NotFoundError: "No camera or microphone was found. You can still join to watch.",
  NotReadableError: "Your camera is in use by another app. Close it, then try again.",
  OverconstrainedError: "Your camera cannot use that quality. Try a lower one.",
};

try {
  media = await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
  const name = err instanceof DOMException ? err.name : "";
  showError(messages[name] ?? "Could not start your camera or microphone. Try again.");
}

In a Flutter app, add the platform permissions listed on the Flutter SDK page. publish() throws when they are refused. After a refusal the system may not ask again, so offer to open the app's page in the system settings.

QualityHost upload, steadyEach viewer downloads
1080p3011 Mbps or more (video sends up to 8.4)about 6.1 Mbps
1080p6015 Mbps or more (video sends up to 11.4)about 9.1 Mbps
720p305 Mbps or more (video sends up to 4)about 3.1 Mbps
480p2 Mbps or more (sends about 1.4)about 1.3 Mbps

A viewer with less download than this is not cut off: they receive a smaller copy.

  • Hosts: a laptop or desktop, plugged in, on a cable or on 5 GHz Wi-Fi near the router, is the most reliable way to send 720p or 1080p.
  • Phones: start at 720p30 with the 0.5.0 defaults. If a phone reports cpu with effects off, send 480p from it. Older and budget phones usually do better at 480p from the start.
  • Effects cost more on phones than on laptops. Keep them light, and let hosts switch them off.
  • Mobile data: 480p, unless the upload is reliably strong.

Video quality has the copies each quality sends and the data each viewer uses.

Show your users what is happening

Most users cannot see that their phone is overheating or their upload is weak. A short, calm notice tells them what to change, and saves a support ticket. Keep it small and dismissible, and show it at most once a minute: it is advice, not an error.

On the host

const off = client.on("videoLimited", ({ reason }) => {
  if (reason === "cpu") {
    notice.show("Your device is busy, so your video is softer. Turn off effects or switch to 480p.");
  } else if (reason === "bandwidth") {
    notice.show("Your connection is slow, so viewers get a lighter picture.");
  } else {
    notice.hide();   // "none" when it cleared, or "other"
  }
});

// When the host stops publishing: off(). Removing the listener also stops the sampling.

On a viewer

let lastLost: number | null = null;
const timer = setInterval(async () => {
  const stats = await client.getReceiveStats(hostVideoProducerId);
  if (!stats) return;
  const lost = lastLost === null ? 0 : stats.packetsLost - lastLost;   // packetsLost counts from the start
  lastLost = stats.packetsLost;
  weakConnectionBadge.hidden = lost < 30;   // packets lost in the last 2 seconds; tune it for your stream
}, 2000);

// When the video goes away: clearInterval(timer).

Lost packets point at the network. A low frame rate alone can also mean the host paused their camera or is overloaded, so check that before blaming the viewer's connection.

In Flutter

The Flutter SDK does not report the reason or the received quality yet. Use onStreamInterrupted and onClosed to show connection state, and onPublishWarning to see when a codec or setting could not be applied.

60 fps and phone heat

60 fps is delivered only when the capture source provides it. Phone makers usually keep 60 fps for their own camera app, so browsers and most apps get 30 fps. Check a camera's supported rates in chrome://media-internals → Video Capture. Billing follows the frame rate actually delivered. See 60 fps needs a 60 fps capture source.

60 fps costs more data, battery and encoding time. Watch getVideoStats() and listen for browser error code frame-rate-fallback, or Flutter onPublishWarning. A fallback continues at 30 fps. Prefer 30 fps and fewer effects on weak phones; lower to 720p or 480p if the device reports CPU limitation. A requested constraint alone is not proof of delivered 60 fps.

Screen sharing problems

  • Start browser publishScreen() from a button click on HTTPS or localhost. Most mobile browsers cannot capture a screen; screen-share-unsupported is a platform limit.
  • A cancelled chooser or denied permission leaves the camera live. Offer a retry rather than asking for camera permission again.
  • Browser audio capture depends on the selected tab/window and platform. An audio checkbox does not guarantee an audio track. Play screen-audio independently of the microphone.
  • Use detail for slides/text, motion for moving content. Native screen size is capped at 1080p, never enlarged. If the viewer is soft, inspect its received layer and choose Auto before blaming capture.
  • Android Flutter needs the documented MediaProjection foreground-service callbacks and an explicit stop action; desktop needs a selected source. iOS is phase 2. A system stop is not reliably forwarded by the current Android plugin.
  • Camera and screen upload together: add 3.1 Mbps (detail) or 6.6 Mbps (motion), before audio, to the camera's full simulcast upload. Leave headroom below the publisher's 25 Mbps safety cap.

See Screen sharing for platform setup and lifecycle examples.

VoltKit Wallet and billing

Every account pays from the VoltKit Wallet, at rates in credits per 1,000 participant-minutes, after its free and previously bought minutes. How the wallet bills is on Usage and billing.

I recharged, but the wallet balance does not go down

Signs. Calls run, and Wallet > Usage shows no credits spent, or fewer than the rates suggest.

Why. Your free and previously bought minutes stay on your account and are always used first; your wallet credits are used after them. A recharge never removes them. Your free trial minutes and any minutes bought before the wallet are being spent, one per participant-minute at any quality.

Fix. Nothing is wrong. RTC > Billing shows the minutes left on the account balance and on each project; the wallet takes over, without a break, once a project has used the minutes it can use.

Calls end although the wallet has credits

Signs. Rooms close with roomClosed and quota-exhausted, or joins are refused with 402, while the Wallet page still shows credits.

Why. A project spends only its own share, then the RTC share, then Unallocated, and never another project's share. The credits you see may sit in other projects' shares, or in a share kept for recording, YouTube or SVGA.

Fix. On Wallet > Allocations, move credits into the project's share or into Unallocated, or change the percentages and use Rebalance now. From your backend, GET /v1/billing/balance (getBalance()) lists exactly what the project may spend, in the order it spends it.

New participants are refused while the call goes on

Signs. Tokens or joins fail with 402 quota-exhausted, and the people already in the room continue, usually after a walletLow.

Why. Joining needs enough credits to pay for the newcomer for about a minute. A live room carries on until the credits reach zero.

Fix. Recharge, or move credits into the project's shares. Set a low-balance alert on Wallet > Alerts so you hear about it before your users do.

The low-credit warning comes and goes

Signs. Your app receives walletLow and then walletLow with cleared: true, several times.

Why. The credits the project may spend are close to one minute at the current rate, and the rate changes as people join, leave or switch media.

Fix. Recharge. Show the banner calmly and hide it on cleared; do not end the call on walletLow alone: the service closes the room itself if the credits reach zero.

A participant is billed for video with the camera off

Why. Each participant is billed one rate, the highest of what they send and what they receive: watching someone's video is video. A screen share is video too.

Fix. Nothing is wrong. For rooms that are meant to be voice only, turn Video RTC off in the project's Settings: then nobody sends video, and everyone is billed for audio.

A voice call is billed as HD audio

Why. A microphone is HD when the app asks for it (audioQuality: 'hd') or when it is measured above 72 kbps. In-app music sent with publishMusic is not the reason: it is HD and free, and never changes anyone's rate. Music played through the speakers into the microphone is part of the microphone, though, and can make it measure as HD.

Fix. Leave audioQuality at its default (standard), and play music with publishMusic rather than through the speakers. To rule HD out for a whole project, set Highest audio quality to standard in its Settings; the service then never bills HD audio for it. In-app music still works there, free.

60 fps is billed later than it started, or not at all

Why. 60 fps is billed only while it is delivered: a stream counts as 60 fps after two measurements at 50 fps or more, about half a minute after it starts, and stops counting as soon as one falls below 40. A camera, device or upload that cannot keep 60 fps is billed at 30.

Fix. Nothing is wrong. getVideoStats() shows the frame rate actually sent; see 60 fps and phone heat.

Publishing is refused, or goes out lower than asked

Signs. media-disabled, frame-rate-not-allowed, video-quality-not-allowed or audio-quality-not-allowed, on a call or as a warning.

Why. The project's media settings do not allow it. The SDKs (0.7.0) follow them and send the most the project allows; the service enforces them whatever a client sends.

Fix. Change the project's Settings, or offer only what mediaPermissions allows. See Errors.

Videos stay small in a large room

Why. With wallet billing a participant receives at most 25 videos at once, and at most 4 of them above 360p; the others are held at 360p. This keeps the video rate the same however large the room is.

Fix. Nothing is wrong. Show at most four large tiles at a time, and small thumbnails for the rest (ask for the smallest copy with setPreferredLayers).

billing-unavailable

Signs. Joins or tokens fail with 503 billing-unavailable, and the SDKs stop reconnecting.

Why. The project is billed from the wallet, and no price list is active on the service. Only VoltKit can fix it.

Fix. Contact support with your App ID.

The usage figures look shifted, or an hour shows as pending

Why. Usage is shown in UTC hours and days. An hour is settled a few minutes after it ends; until then it is pending. The Live tab of a project is an estimate; settled amounts are final.

A recharge was paid but no credits arrived

Why. Credits are added only after our server has verified the payment with Razorpay: from the checkout, Razorpay's webhook, or a recovery job that checks unfinished payments. Closing the checkout early only delays it.

Fix. Open the recharge on Wallet > History to see its state. If it is still not credited after a few minutes, write to us with the Razorpay payment ID (it starts with pay_); we add the credits or refund the payment.

The wallet is on hold

Why. A payment dispute (chargeback) was opened on one of its recharges. While it is reviewed, recharges, transfers and rebalances are held, and the credits of the disputed payment that are still unused are taken back. Calls go on with what is left.

Fix. Contact support, and tell your bank if the dispute was a mistake. If it is decided in your favour, the credits come back.

Still stuck?

Open a ticket from the Support page of your dashboard, or write to contact@voltkit.in, with:

  • Your App ID. Never send the API secret.
  • The room id, and the identities of the host and of a viewer who saw the problem.
  • When it happened, with the time zone.
  • The SDK and its version, for example @voltkit-rtc/client 0.5.0 or voltkit_rtc 0.5.0.
  • The browser or app, the operating system and the device model.
  • The network: home Wi-Fi, office, mobile data, VPN.
  • getVideoStats() from the host and getReceiveStats() from an affected viewer, taken while it was happening, and any error code.
  • A webrtc-internals dump, if you have one.

Last updated . Something unclear or wrong? Tell us.