Track publish succeeds server-side (TrackInfo returned, ICE+DTLS confirmed complete) but zero video bytes ever received — confirmed across two different networks

We have a React Native app with two client roles: a publisher client (native Kotlin using io.livekit:livekit-android:2.26.1) publishing a custom video track (via a custom VideoCapturer), and a subscriber client (@livekit/react-native + livekit-client) subscribing to it. Connection, signaling, ICE, and DTLS all succeed — but no video ever reaches the subscriber, and we’ve now ruled out network path as the cause.

What we’ve confirmed, with evidence:

  • Publisher: local encoder produces real frames (framesEncoded, bytesSent climbing steadily, real keyframes, encoderImplementation=libvpx) via Track.getRTCStats()
  • Publisher: publishVideoTrack() returns successfully; requestAddTrack()'s server round-trip completes (confirmed since track.statsGetter populates, which only happens after negotiate() runs, which only runs if TrackInfo came back non-null)
  • Publisher (verbose native WebRTC logging, LS_VERBOSE): ICE reaches CONNECTED, DTLS handshake explicitly completes, channel.cc: Channel writable (mid: 1, media_type: video) fires, encoder confirms not suspendedon two structurally different networks:
    • WiFi: IPv4, local srflx (STUN-derived) ↔ remote host, NAT traversal involved
    • Cellular: IPv6, local host ↔ remote host, direct connection, zero NAT traversal
    • Identical full success sequence on both, identical result: no video delivered
  • Subscriber: subscribes correctly (subscribed: true, hasTrack: true), valid MediaStream/streamURL, native renderer attaches, addSink() succeeds — but receive_statistics_proxy.cc shows framesDecoded: 0 and VideoReceiveStreamInterface shows total_bps: 0 for the entire session
  • LiveKit Cloud dashboard, consistent across multiple sessions:
    • Publishers table: “No results” — zero publishers ever registered
    • Session Events: participant join/active/leave logged for both roles, but no “Track Published” event anywhere
    • Total upstream ~21KB / downstream ~15KB for a ~2.5 min session — consistent with signaling/ICE/DTLS overhead only

Summary: every milestone the client’s own WebRTC engine can report succeeds — real candidate pair selected (pointing at the actual SFU IP), DTLS-SRTP completes, video channel confirms writable, encoder active — reproducibly, across two networks with fundamentally different NAT/routing characteristics. The server’s own dashboard shows no publisher was ever registered and no meaningful media volume. This gap sits between “client believes it’s writable and encrypted” and “server’s ingest pipeline registers the media” — invisible to anything client-side logging can show us.

Question: could someone check server-side ingest/routing logs for these sessions directly? Session IDs below.

  • Session: session-302a536b-31cd-45e4-817d-4e3d85ff5c12, Room: RM_pTSh6H7J8sDK, Node: NM_OASHBURN1B_Evt2ajbUUjSH, region oashburn1b
  • (additional session IDs available for the cellular-network test if useful)

Happy to share full client-side logs (JS and native Kotlin/Java, including the verbose WebRTC output) if useful.

Update: Attempted to force TURN-relay-only on the publisher side to isolate whether our direct/reflexive ICE paths are the actual problem, but hit a real SDK-level constraint worth sharing:

  • PeerConnection.getConfiguration() doesn’t exist in this WebRTC build (org.jitsi:webrtc:124.0.0, bundled in io.livekit:livekit-android:2.26.1), so we can’t read back the live, already-negotiated TURN credentials to construct a relay-only config client-side after connecting.
  • The real ICE server list from the JoinResponse is only ever used internally by the SDK’s own default connect/reconnect paths — it’s a local variable in RTCEngine.makeRTCConfig(), never cached or exposed anywhere reachable from app code without deep internal reflection.
  • Supplying our own ConnectOptions.rtcConfig before connect() doesn’t work either — makeRTCConfig() only merges connectOptions.iceServers into a user-supplied rtcConfig’s own iceServers, it never falls back to the real server-issued ones. A relay-only config built this way would have zero valid TURN servers to relay through, and ICE gathering would just fail outright — not a meaningful test.
  • We did find the SDK already has a mechanism built for exactly this: clientConfig.forceRelay, read from JoinResponse.clientConfiguration — but it’s server/project-controlled, not something exposed as a client SDK option.

Question: could this be enabled for our project (or a single test session/room) on your end? That would let the SDK’s own default path handle it correctly with valid TURN credentials, rather than us attempting a fragile client-side workaround that’s structurally unlikely to produce a meaningful result.

Happy to run the test immediately and report back the moment it’s available

The ~21 KB upstream over 2.5 minutes is your ground truth, and it contradicts the bytesSent you’re reading. Real VP8 video for that long would be megabytes, so whatever is climbing isn’t reaching the wire resolve that contradiction before anything else, because it splits the problem cleanly in half.

outbound-rtp counts at the RTP layer, so bytesSent climbing only tells you packets were handed to the transport not that ICE put them on the wire. The stat that answers that is RTCTransportStats.bytesSent, cross-checked against the candidate-pair you confirmed as selected both on the publisher PeerConnection, not the subscriber one. If transport bytesSent is also ~21 KB, nothing ever left the device. If it’s megabytes, the packets left and something upstream is dropping them, which is when it’s worth staff pulling the ingest logs.

I’d lean toward the first, because “no publishers ever registered” and no Track Published event is a signalling-level fact, not a media one. Your mid: 1 video channel being writable means SDP negotiated fine but LiveKit’s track registration is a separate layer on top of that, and requestAddTrack() returning client-side doesn’t prove the server completed it.

On forceRelay you're right that it's server-controlled, it's read from JoinResponse.clientConfigurationin [RTCEngine.kt](https://github.com/livekit/client-sdk-android/blob/main/livekit-android-sdk/src/main/java/io/livekit/android/room/RTCEngine.kt), so only LiveKit staff can flip it for your project. But I don't think it's worth chasing: your ICE already reachedCONNECTED` on a pair pointing at the real SFU IP, and forcing relay only changes which path is used, not whether media flows over one that’s already established.

One more thing worth ruling out given the custom VideoCapturer confirm the capturer is delivering into the VideoSource’s CapturerObserver for the track you actually published, rather than a second source that only feeds your local preview. That’s the usual way you get a healthy-looking local encoder and an empty wire.

Thank you for following up @abidullahcs.uk I really appreciate it. I have a follow up update -

Pulled the dashboard for a fresh test session (RM_ZPtYsSf9wJXD) and the
picture has changed significantly:

Publisher side (dashboard-confirmed, server-side):

  • Publishers table now shows a real registered publisher with video+audio tracks
  • Average FPS: climbs to ~15fps, drops to 0 exactly at session end
  • Average bitrate: climbs to ~8 Kbps, drops to 0 exactly at session end
  • Total upstream: 172.41 KB (consistent with real, working video encoding)

Subscriber side (same session, same dashboard):

  • Average FPS: flat 0fps for the entire session
  • Average bitrate: flat 0 bps for the entire session

This confirms real, working video is reaching LiveKit’s server from the publisher —
your own dashboard shows it. But it’s never being forwarded to the subscriber. This
narrows the problem specifically to server-side SFU forwarding/routing between a
confirmed-good publish and a confirmed-empty subscription, not media reaching your
infrastructure at all (which is now settled).

Session ID: session-59e0036d-68f3-48f9-9061-3c6c7190d922, Room: RM_ZPtYsSf9wJXD

Could someone check the SFU-side forwarding/routing logs for this exact session?
Given publish is confirmed healthy and subscribe is confirmed empty, this feels like
it should be visible in your own server logs.

8 Kbps at ~15fps isn’t healthy video that’s the tell. Real VP8 at that framerate runs in the hundreds of Kbps, so 172 KB for a whole session looks more like a paused stream than a working one.

I’d check adaptive stream before SFU forwarding. It pauses a track server-side when the attached element is hidden or has no size, and on the JS SDK it only tracks elements passed through Track.attach() addSink() on the RN native renderer isn’t that path. Once the subscriber reads as “not consuming”, Dynacast pauses the publisher’s layers, which is exactly how you get a registered publisher stuck at 8 Kbps. One mechanism, both of your numbers.

One run settles it:

new Room({ adaptiveStream: false, dynacast: false })

If video flows with both off, it’s the subscriber’s attach path. If it’s still empty, you’ve genuinely narrowed it to forwarding.

Refs:

Thank for the reply Abid,

I Ran the suggested test — new Room({ adaptiveStream: false, dynacast: false }) on the subscriber (React Native / Android). Real hardware end-to-end: physical Ray-Ban Meta glasses → employee app accepts session → customer app subscribes. Session ID: session-82b04533-eb0a-4eab-bd87-781fd747bdb6.

Result: black screen persists, and the subscriber-side native stats are unchanged from every prior test — webrtc_video_engine.cc’s VideoReceiveStreamInterface stats show total_bps: 0, framesDecoded: 0, framesDropped: 0, network_fps: 0, cum_loss: 0, nackCount: 0 across the full session (13 consecutive samples), and receive_statistics_proxy.cc logs Frames decoded 0. This is with subscribed: true, hasTrack: true, a live MediaStream (mediaStreamActive: true), and (confirmed in an earlier native-logging pass) the Android SurfaceViewRenderer.addSink() call succeeding.

Meanwhile the publisher side for the same session is healthy the whole time — outbound-rtp stats show framesEncoded/bytesSent climbing continuously (0→86 frames, 0→2868 bytes in the sampled window), encoderImplementation=libvpx, and transport-layer candidate-pair stats confirm real bytes leaving the device on a succeeded/nominated pair (bytesSent climbing 911→8549).

Since disabling both adaptiveStream and dynacast didn’t change the subscriber’s zero-bytes-received result at all, I don’t think this is a Track.attach()/visibility-detection issue on our end — the subscriber’s own WebRTC engine reports zero packets received regardless of that setting. This points at server-side forwarding/routing for this project rather than a client-side attach/pause misdetection. Happy to provide any further client-side traces, but at this point I think it needs eyes on the SFU side for this session.

Fair enough that rules out adaptive stream. Thanks for running it.

The publisher numbers still look wrong: 86 frames for 2868 bytes is ~33 bytes/frame. LiveKit’s smallest 16:9 preset (VideoPreset169.H90, 160×90 @ 15fps) budgets 90 Kbps about 750 bytes/frame and the SDK default is H720 at 1.7 Mbps (LocalVideoTrackOptions.kt). Your candidate-pair figures agree: 911→8549 is ~7.6 KB for the whole session.

Packets are leaving, but carrying essentially nothing which is what VP8 emits when consecutive frames are identical. That points at the custom capturer and whether the Ray-Ban feed reaches source.capturerObserver, not at SFU forwarding.
Quickest check: publish one session from the stock camera capturer. If bytes-per-frame jumps into the hundreds, the glasses capturer isn’t delivering real frames.

Thanks for running that test with a session ID, that let me pull the server-side logs for session-82b04533-eb0a-4eab-bd87-781fd747bdb6.

Here’s what the SFU recorded for that session: your video track (VP8, 1280x720, camera source) was published with muted: true, and it stayed muted for the entire session. No unmute ever arrived. The server also confirms your test was applied correctly, the subscriber joined with adaptiveStream: false — and ICE was healthy on both sides throughout.

That one fact explains every number you’ve both posted:

  • The SFU forwards nothing for a muted track, that’s intended behavior, not a routing fault. Hence total_bps: 0, framesDecoded: 0, and zero NACKs/loss on the subscriber: there are no packets to lose.
  • On the publisher, a muted/disabled track doesn’t stop the encoder, libvpx keeps producing frames, they’re just empty placeholders. That’s your ~33 bytes/frame and @abidullahcs.uk’s 8 Kbps observation. framesEncoded climbing is not evidence that real frames are flowing.
  • It reproduces across networks because it’s client state, not network.

We also checked an earlier session of yours and found the identical signature, so this is consistent, not a one-off.

I think this rules out the SFU side, the issue is in the app’s publish path: the track is muted at publish time and never unmuted. Quickest way to see it yourself: log videoTrack.muted right after publishTrack() resolves on the publisher, and/or listen for TrackMuted on the subscriber — you should see it fire immediately on subscribe.

Given the custom Ray-Ban capturer, the usual suspects:

  1. Publishing before the capturer has started — if the track isn’t receiving frames / isn’t enabled when publishTrack() is called, it goes up as muted.
  2. An explicit mute() / enabled = false somewhere in your accept-session flow that’s never undone.
  3. The glasses feed never reaching capturerObserver, so the track never transitions to unmuted — which lines up with @abidullahcs.uk’s suggestion to test once with the stock camera capturer.

If you fix the mute state and still see black video, post the new session ID and we’ll take another look.

Some potentially helpful references:

  1. Mute and unmute | LiveKit Documentation
  2. LocalTrackPublication.muted (Android)
  3. RoomEvent.TrackMuted (Android)
  4. RoomEvent.TrackMuted (JS)
  5. LocalParticipant.createVideoTrack (Android)