gr.WebRTC design notes

How WebRTC streams work in Gradio after PR #13965: the component, the queue changes it relies on, the process executor, and gr.Server with the JS client. This page explains the design; the PR description lists the changes to shared code and what to review.

Describes the PR as of commit a2df195. Part of #13907.

1. The idea in one paragraph

Until now, WebRTC in a Gradio app meant the external FastRTC library, whose component connected each browser directly and kept its own limits: past concurrency_limit a visitor was simply rejected. This PR moves the component into Gradio core as gr.WebRTC and makes each stream one queue event, from the moment the user presses Record until the stream ends. Media still goes over a peer connection and never enters the queue, but the queue decides when a stream may start and when it must end. So concurrency_limit and time_limit mean what they mean for any other event: a visitor beyond the limit waits in line with a place and an ETA, and starts by themselves when a slot frees up.

import gradio as gr
from gradio.webrtc import AdditionalOutputs

def process(frame, flip):
    out = frame[::-1] if flip else frame
    return out, AdditionalOutputs(frame.shape)

with gr.Blocks() as demo:
    webrtc = gr.WebRTC(mode="send-receive", modality="video")
    flip = gr.Checkbox(label="Flip")
    shape = gr.Textbox()
    webrtc.stream(process, inputs=[webrtc, flip], outputs=[webrtc, shape],
                  time_limit=60, concurrency_limit=2)

2. Four channels between the page and the server

A stream uses four separate channels. Only the first one is the queue; the media never passes through it.

Browser gr.WebRTC or client.webrtc() Gradio server queue, aiortc, the handler 1. Queue: /queue/join and SSE place in line, ETA, AdditionalOutputs, gr.Info / gr.Warning, errors, completion 2. Signalling: POST /gradio_api/webrtc/* rtc_configuration (TURN credentials), SDP offer and answer, ICE candidates 3. Media: WebRTC (UDP, through TURN if needed) video and audio frames; the queue is not involved 4. Data channel (inside the peer connection) input values (gr.Server), a handler asking for inputs, the end of the stream gr.WebRTC only: extra inputs as short private events that skip the queue
gr.WebRTC sends the values of its other input components through events, so that their preprocess runs. A gr.Server page has no components, so client.webrtc() sends them as JSON on the data channel.

3. The life of a stream

The diagram follows one stream from Record to the end. The four lanes are the page, the queue, the stream event's run (a generator the queue steps), and the Stream object in gradio/webrtc/connection.py that owns the peer connection.

Page gr.WebRTC / client Queue queueing.py, SSE Event run stream_run (a lease) Stream connection.py + aiortc wait queue/join [webrtc_id] estimation: 2/3 · ~25s turn first step admit(webrtc_id) ADMITTED process_starts connect open camera / mic, POST /webrtc/rtc_configuration (only for the admitting session) POST /webrtc/offer (SDP, ICE candidates), answer comes back CONNECTING stream media over WebRTC; the handler runs LIVE AdditionalOutputs: wake() step yields outputs process_streaming end POST /stream/{id}/close close_stream: Stream.close() closes the peer connection at once ENDING wake() last outputs + finish() process_completed .success() and .then() run ENDED
No camera or microphone is opened while the stream waits in line.

4. Stream states and how a stream ends

A Stream exists from its admission until its queue event is over, and it has a single way to end: close(). Every reason to stop goes through it, so the handler shuts down, the media stops and the pending outputs are delivered the same way each time.

In line no Stream yet ADMITTED may get TURN config CONNECTING ICE in progress LIVE media flows ENDING delivers what is left ENDED out of the registry first step offer connected close() event over close() from any of these states Stop or cancels= · the handler returns CloseStream · a handler error the peer connection closes or fails · time_limit (from admission) not connected within 30 s · the event is no longer live
Stopping a stream that is still in line creates no Stream: the event leaves the line and the page runs .failure() and .then(), as for a cancelled event.

Each stream has a watcher that checks its time limit, the connect timeout and whether its event is still live every 0.25 s. An error from the handler ends the stream and fails its event, so the page shows it once and .failure() runs, as for any event whose function raises.

5. Two kinds of slots

A running event normally holds two things from start to finish: a place in its function's concurrency_limit (the GPU, say) and one of the queue's worker slots (max_threads, 40 by default). A stream spends almost all of its life waiting for the next chunk or output, so holding a worker slot all that time let 40 open streams block every other event in the app. Streams now keep the first and give back the second while they wait.

Concurrency place Worker slot Media Record turn connected output output output Stop done
Orange: the place in concurrency_limit, held from the turn to the end. Dark blue: a worker slot while a step runs. Light blue: waiting to be woken, with no worker slot. Green: the media, which stops at once when the stream ends.

The lease

A gr.WebRTC stream event is a lease (BlockFunction.lease): an async generator that the queue steps each time the event is woken. The queue does not time out its wait and does not cut it at its time limit; the stream keeps its own time limit, because the queue's cut would end the event before it delivers what is pending. A step that calls EventHandle.finish() is the last one, and its outputs go out with the completion. Only gr.WebRTC and server.webrtc() events are leases.

Taking a slot back

A waiting stream takes a worker slot back with take_worker_back when it has something to do, ahead of new events, so a busy queue does not stall running streams. gr.Audio and gr.Image streams get the same treatment between chunks, which is why the PR also changes their behavior.

RunStep

The route that runs a call used to infer from the event id whether the call continues the generator left by the event's previous call. Stream calls now keep their event id (so gr.Info and progress work after the first chunk), and the queue, which alone knows, says it explicitly with a RunStep: the event's first call, a call that continues the run, and whether to keep the run for later.

Place in line

For the ETA, a running stream counts as holding its place for its time_limit, so each stream ahead in line adds time_limit / concurrency_limit. Without a time limit there is no ETA. The line also moves up as soon as someone leaves it.

6. One code path for gr.WebRTC and gr.Server

The component and gr.Server register the same kind of event, and everything after that is shared. Signalling and inputs find a stream by its id in a registry, never through the component instance: a gr.update() gives a session a new copy of the component, and every session's copy has the same component id.

gr.WebRTC.stream() components/webrtc.py · inputs through events server.webrtc() server.py · inputs over the data channel same checks same checks endpoint.add_stream_event() a queued event: connection="stream", lease, close_stream connection.stream_run() first step: ConnectionManager.admit(webrtc_id) StreamRegistry one per app (per queue) streams[stream_id] by_event[event_id] pending ICE candidates signalling and inputs look the stream up here by id Stream (one object per stream) QueueLease: the event handle, time limit watcher (every 0.25 s) peer connection and tracks, to the handler StreamInputs: the extra input values outputs: oldest first, at most 64 waiting errors (first failure, reported once), state
The handler classes live in handlers.py and the frame plumbing in tracks.py. Only connection.py, tracks.py and bitrate.py need aiortc, so the component can be built on a plain install and .stream() asks for the webrtc extra.

Inside @gr.render, .stream() raises: a render that runs again replaces the events it made, including a running stream's, and with the process executor the next call of the replaced event would fail.

7. With executor="process"

With the process executor (the default on 7.0-dev), the queue runs in a gateway process and the app's functions in app processes. A stream's function, its ConnectionManager, its handler and its media all live in the app process that admitted it, while the queue that steps it lives in the gateway.

Browser stream s1 Gateway (the queue) Queue, events, leases Pool placements[event_id] stream_placements[stream_id] session_streams[session] one Placement per stream event: its process, works_here, stream id Link (one per process) applies event_action messages App process A Worker.run_event RemoteEventHandle StreamRegistry, Stream s1 ConnectionManager aiortc and the handler App process B other sessions HTTP call (RunStep, per-process limit) result + event_actions event_action (wake between steps) end_event, close_stream offer for s1, forwarded by id media goes straight between the browser and process A, not through the gateway
The gateway keeps one Placement per stream event and looks it up three ways: by event, by stream id (for signalling) and by session (so the session's other events follow its stream).

8. The page side

Following the run it started

Gradio.dispatch(event, data, observer) takes an optional observer that hears when the run of the event's function starts (with a handle that ends just that run), its statuses (place in line, ETA, stream state) and how it ended, exactly once on every path. gr.WebRTC uses it to know its turn and to end only its own run. A call without an observer behaves as before.

Ending on the page still finishes on the server

When the page ends a stream (Stop, CloseStream, a server-side end it notices), it asks the server to end it and reads the event to its end (close_stream({ read_to_end: true }) in the JS client), so the last outputs show and .success() or .failure() runs. Before, the page closed its iterator at once and the last outputs were lost; this applies to gr.Audio and gr.Image streams as well.

Reloads

A stream cannot continue in a new page. With session resume, a reloaded page now asks the server to end its resumed streams and reads what the server sends up to their end, instead of resuming them and leaving them to hold a slot until they time out.

gr.Server and the JS client

server.webrtc() registers a handler under an endpoint name, and the page streams to it with client.webrtc(), which joins the queue, takes the first message as the turn, connects, sends the inputs and delivers the outputs. The connection code of gr.WebRTC's frontend lives in @gradio/client and is exported for custom pages. When a handler takes gr.OAuthToken, the app gets the same sign-in routes as a Blocks app with a LoginButton, which is how the guide issues TURN credentials on Spaces.

const stream = app.webrtc("/flip", {
  media: await navigator.mediaDevices.getUserMedia({ video: true }),
  inputs: [1.0],
  on_track: (event) => (video.srcObject = event.streams[0]),
  on_output: ([mean]) => console.log(mean)
});
stream.set_inputs([1.5]);
await stream.stop();

9. Measurements

Headless Chromium on one machine, fake camera at 20 fps, a handler that returns each frame with AdditionalOutputs(server time), concurrency_limit=None. Output lag is the browser's clock minus the server time in the output.

1 stream, max_threads=405 streams, max_threads=25 streams, max_threads=40
Click to queue join / offer sent / connected37 / 60 / 115 ms~49 / ~117 / ~240 mssimilar
Output lag p50 / p956 / 7 ms20 / 26 ms20 / 28 ms
Output updates per second per user20.620.821.0
Plain button p50 / max64 / 67 ms65 / 81 ms76 / 133 ms
Busy worker slots, max124

With max_threads=2, two open streams used to exhaust both slots, and the plain button never returned. The extra lag with five streams comes from five browsers encoding video on one machine, not from waiting for a slot. With concurrency_limit=1, the second user starts 109 to 130 ms after the first stops.

10. For FastRTC users

What changes in code written for FastRTC's component:

Questions and comments go on the PR. The demo Spaces linked from the PR description show these streams running, including on ZeroGPU.