Skip to main content

DataChannel

A backpressure-aware wrapper around an aiortc data channel, exposing an async send() and typed events. Use it when you're sending more bytes per second than the channel's buffer can sustain — telemetry, file transfer, game state at high frequency — and could outpace the send queue.

You always wrap a raw channel: either one you opened with remote.create_data_channel(...) (outbound), or one the remote opened, delivered on the DataChannelOpened event (inbound).

from metered_realtime import DataChannel, DataChannelOpened

# Outbound — you open the channel:
raw = remote.create_data_channel("file-transfer")
dc = DataChannel(raw)

# Inbound — the remote opened it:
@remote.on(DataChannelOpened)
def on_dc(ev: DataChannelOpened) -> None:
dc = DataChannel(ev.channel) # ev.channel is the raw channel

Why backpressure matters​

Calling send() faster than the channel can flush would otherwise grow an unbounded buffer, climbing memory until the process runs out. DataChannel throttles sends against the channel's buffered byte count: send() suspends while the buffer is over max_buffered_amount and resumes as it drains, with FIFO ordering across concurrent senders. Past a hard queue cap it raises instead of buffering forever, so a producer on a stalled link gets a real back-off signal.

Construct​

from metered_realtime import DataChannel, DataChannelOpened

def wrap(raw) -> DataChannel:
return DataChannel(
raw,
max_buffered_amount=1_048_576, # suspend sending while buffer > 1 MB (default)
max_queued_sends=256, # raise if 256 sends are already queued (default)
)

# Creating side (exactly one per pair -- the impolite one), in your PeerJoined
# handler; creating the channel is what starts the P2P connection, so don't
# wait for a "connected" state:
if not remote.polite:
dc = wrap(remote.create_data_channel("file-transfer"))
# …use dc.send below

# Receiving side -- the same channel arrives via DataChannelOpened:
remote.on(DataChannelOpened, lambda ev: wrap(ev.channel))

Constructor​

DataChannel(
channel, # the raw aiortc data channel (positional)
*,
max_buffered_amount=1_048_576, # 1 MB
buffered_amount_low_threshold=None, # defaults to max_buffered_amount // 2
max_queued_sends=256,
buffer_poll_interval=0.1, # seconds
logger=None,
)
ParameterTypeDefaultNotes
channelraw aiortc data channel—From remote.create_data_channel(...) or the DataChannelOpened event.
max_buffered_amountint1_048_576 (1 MB)When the buffered byte count would exceed this, send() suspends until the buffer drains.
buffered_amount_low_thresholdint | Nonemax_buffered_amount // 2Resume threshold. A non-zero value is what makes the underlying drain event fire at all.
max_queued_sendsint256Hard cap on concurrently queued sends. The (256+1)th raises DataChannelOverflowError.
buffer_poll_intervalfloat0.1Seconds between defensive buffer re-checks while suspended, so a send can't wait forever if the drain event never re-fires on a settled buffer.
loggerLogger | NoneNoopLoggerOptional structured logger.

Methods​

await dc.send(data) → None​

data is str or bytes. Queues a send; resolves once the payload is handed to the underlying channel. Suspends while the buffer is over max_buffered_amount and resumes as it drains. Concurrent calls are chain-sequenced (FIFO) so a single drain wake-up can't stampede the buffer.

for chunk in file_chunks: # chunk: bytes
await dc.send(chunk) # backpressure-aware

Raises:

  • DataChannelOverflowError — max_queued_sends sends are already waiting. Your producer is faster than the network: throttle upstream or raise the cap. Carries .queued and .cap.
  • MeteredRealtimeError — the channel is closed (or closes mid-send), or a single payload is larger than max_buffered_amount (it could never drain below the cap and would otherwise suspend forever — split it or raise the cap).
from metered_realtime import DataChannelOverflowError

try:
await dc.send(chunk)
except DataChannelOverflowError as e:
# e.queued = current pending count, e.cap = your max_queued_sends
pause_producer()

dc.close() → None​

Synchronous. Closes the underlying channel and detaches listeners. Idempotent. Any suspended send() is released and fails fast. Wrapping a channel that the remote closes (or whose connection drops) fires DCClose on its own — close() is what you call when you are done with it.

Read-only state​

dc.label # str — the channel label (e.g. "file-transfer")
dc.ready_state # "connecting" | "open" | "closing" | "closed"
dc.buffered_amount # int — current bytes pending in the underlying buffer

Events​

Register typed event classes with the @dc.on(EventType) decorator (or dc.on(EventType, handler)); dc.once(...), dc.off(...), and async for ev in dc.events(EventType): ... also work. Handlers may be sync or async.

EventFieldsWhen
DCOpen—The channel transitioned to open.
DCClose—The channel closed (either side, or its connection dropped).
DCMessagedata (str | bytes)An inbound payload arrived.
DCErrorerr (Exception)The underlying channel reported an error — distinct from a send() failure, which raises from the await instead.
from metered_realtime import DCOpen, DCClose, DCMessage, DCError

@dc.on(DCOpen)
def _open(ev: DCOpen) -> None:
print("channel open")

@dc.on(DCMessage)
def _msg(ev: DCMessage) -> None:
handle(ev.data) # str or bytes

@dc.on(DCError)
def _err(ev: DCError) -> None:
log.warning("dc error: %r", ev.err)

@dc.on(DCClose)
def _close(ev: DCClose) -> None:
print("channel closed")

What survives a reconnect​

Nothing. A DataChannel wraps a channel that is tied to the RTCPeerConnection it was opened on. When the SDK re-establishes a survivor's connection on reconnect, every channel on the old connection closes (you'll see DCClose).

The right pattern: the impolite side re-opens the raw channel and re-constructs the wrapper when the peer returns to "joined" after a reconnect; the other side re-wraps the channel that arrives again via DataChannelOpened. One channel carries both directions (don't gate creation on the per-peer StateChange → "connected" — a data-only app never reaches it until a channel exists):

from metered_realtime import (
DataChannel, DataChannelOpened, DCMessage, PeerJoined, PeerLeft, StateChange,
)

channels = {} # peer id -> DataChannel

def wire_channel(remote, raw) -> None:
old = channels.pop(remote.id, None)
if old is not None:
old.close()
dc = DataChannel(raw, max_buffered_amount=1_048_576)
dc.on(DCMessage, lambda m: handle(m.data))
channels[remote.id] = dc

def maybe_create_channel(remote) -> None:
if remote.polite: # exactly one creator per pair
return
wire_channel(remote, remote.create_data_channel("file-transfer"))

@peer.on(PeerJoined)
def on_join(ev: PeerJoined) -> None:
remote = ev.peer
remote.on(DataChannelOpened, lambda dco: wire_channel(remote, dco.channel)) # receiving side
maybe_create_channel(remote)

@peer.on(StateChange)
def on_peer_state(sc: StateChange) -> None:
if sc.from_ == "reconnecting" and sc.to == "joined":
for remote in peer.remote_peers:
maybe_create_channel(remote)

@peer.on(PeerLeft)
def on_left(ev: PeerLeft) -> None:
dc = channels.pop(ev.peer.id, None)
if dc is not None:
dc.close()

See Reconnect Best Practices for the full pattern including reconnect-aware producer pause/resume.

When NOT to use this wrapper​

  • Server-routed messages. peer.send(data) / remote.send(data) already have their own queueing at the wire layer. DataChannel is specifically for P2P channels.
  • Reliable file transfer with chunk-level retry. This wrapper handles backpressure, not retry-on-failure. If the channel closes mid-transfer, you need application-level resume logic.

See also​