Luke Oliff.

Five WebSocket patterns for real-time audio streaming

·Engineering·7 min read·Luke Oliff

Most streaming audio APIs use WebSockets instead of HTTP. The reason is obvious once you’ve worked with both: a persistent bidirectional connection avoids the per-request overhead of TLS negotiation and lets the server push audio chunks as they’re generated rather than waiting for the client to poll.

But a WebSocket connection in production is different from one you open in a demo script. Networks drop packets, servers restart, and clients go offline mid-stream. The patterns that handle these realities are not in the API docs. They’re learned the hard way, usually during the first production incident.

These five patterns come from maintaining SDKs for streaming audio APIs across multiple languages. They apply whether you’re building a speech-to-text integration, a TTS player, or a voice agent pipeline.

1. Reconnect with exponential backoff and jitter

A WebSocket connection will drop. Not if. When. The server restarts, the load balancer cycles, the client’s network flakes for 200 milliseconds. The question is not whether your client reconnects. It’s whether the reconnection happens without audio loss.

The naive approach is to reconnect immediately on close. That works for the first drop. But if the server is genuinely down, a reconnect storm makes things worse. Every client hammering the server simultaneously delays recovery.

Exponential backoff solves this. Start with a 1-second delay. Double it on each failure. Cap at 30 or 60 seconds. Add random jitter (plus or minus half the current delay) so reconnecting clients spread out rather than hitting in waves.

import random
import asyncio

async def connect_with_backoff(url):
    delay = 1
    max_delay = 30
    while True:
        try:
            ws = await connect(url)
            return ws
        except ConnectionError:
            jitter = random.uniform(-delay/2, delay/2)
            await asyncio.sleep(delay + jitter)
            delay = min(delay * 2, max_delay)

The jitter is what stops the thundering herd. Without it, every client that disconnected at the same time retries at the same time.

2. Application-level heartbeat frames

TCP keepalive exists but it’s not reliable enough for real-time audio. A TCP keepalive can take two hours to detect a dead connection on some configurations. You need heartbeats at the application layer.

Send a small JSON frame (something like {"type": "ping"}) every 15 to 30 seconds. The server responds with {"type": "pong"}. If you don’t hear a pong within the timeout window, close the connection and let your reconnect logic handle it.

// Client sends:
{"type": "ping", "timestamp": 1713200000000}

// Server responds:
{"type": "pong", "timestamp": 1713200000123}

Why this matters for audio: a silent WebSocket drop means the client thinks it’s still connected while the server has no idea the client exists. Audio sent into the void is lost. The user hears silence and assumes the system is broken. A missed heartbeat catches this within seconds.

Some streaming APIs build heartbeats into the protocol. For the ones that don’t, implement your own. Check the time since the last received message. If it exceeds your threshold, send a ping. If the pong doesn’t come back, reconnect.

3. Backpressure-aware message buffering

Streaming audio creates a tension between latency and throughput. You want to send audio chunks as fast as they arrive, but if the WebSocket send buffer fills up, you start dropping frames or blocking the event loop.

Backpressure means the producer (your audio source) respects the consumer’s (the WebSocket connection’s) capacity. When the send buffer is near full, slow down or drop non-critical frames rather than queueing indefinitely.

async def send_with_backpressure(ws, audio_chunk, max_buffer_size=65536):
    buffer_size = ws.get_send_buffer_size()
    if buffer_size > max_buffer_size:
        await asyncio.sleep(0.1)
    await ws.send(audio_chunk)

The right approach depends on your use case. For speech-to-text, dropping a few milliseconds of audio is better than building up seconds of latency. The model will catch up on the next chunk. For TTS, you want to buffer more aggressively because a gap in output audio is immediately noticeable to the listener.

Most SDKs I’ve worked with get this wrong by default. They optimise for throughput and let the buffer grow until the process runs out of memory or the latency spikes. Being explicit about backpressure turns a silent failure into a controlled trade-off.

4. Graceful shutdown with drain and finalise

When a user closes the app or the audio stream ends, the connection needs to shut down in an orderly way. Sending a close frame and walking away means the server might still be processing your last audio chunk when the connection drops. The transcript or synthesis result never arrives.

Graceful shutdown has three phases:

  1. Drain: Stop sending new audio. Send a message telling the server the stream is complete (most APIs use something like {"type": "end_of_stream"} or a specific close frame).
  2. Wait: Give the server time to process the final audio and send back any remaining results. A configurable timeout (5 to 10 seconds is reasonable for most streaming audio APIs).
  3. Close: Send the WebSocket close frame and clean up resources.
async def graceful_shutdown(ws, timeout=5):
    await ws.send({"type": "end_of_stream"})
    try:
        async with asyncio.timeout(timeout):
            async for msg in ws:
                process_final_results(msg)
    except asyncio.TimeoutError:
        pass
    await ws.close()

The timeout is important. Some audio models take longer to finalise a long utterance than a short one. A generous timeout with a hard cap means you never leave the user waiting indefinitely, but you also don’t cut off mid-response.

5. WebSocket health monitoring with multi-signal detection

A WebSocket can be in several failure states that look identical from the client side. The socket appears open. The TCP connection is alive. But no data flows. The server is overloaded, the upstream model is slow, or there’s a silent protocol error.

Health monitoring needs multiple signals:

  • Message latency: How long between sending an audio chunk and receiving a response? If it exceeds a threshold, consider reconnecting.
  • Sequence gaps: Does every message get a response? If not, something in the pipeline is dropping frames.
  • Memory growth: Is the send or receive buffer growing unsustainably? That indicates a backpressure problem your reconnect won’t fix.
class StreamHealth:
    def __init__(self):
        self.last_send = 0
        self.last_recv = 0
        self.send_count = 0
        self.recv_count = 0

    def check(self):
        now = time.monotonic()
        latency = now - self.last_recv if self.last_recv else 0
        gap = self.send_count - self.recv_count
        return {
            "latency": latency,
            "unconfirmed": gap,
            "healthy": latency < 5 and gap < 50
        }

Latency spikes are normal in streaming audio. A single slow response does not mean the connection is dead. But sustained high latency combined with growing unconfirmed messages is a reliable signal that the connection is degrading. Reconnecting in that state is cheaper than waiting for the connection to recover on its own.

FAQ

What happens to in-flight audio when a WebSocket reconnects?

Most streaming APIs handle this by treating each connection as an independent session. Audio sent on the old connection is lost. The client needs to resend audio from the last known good position. Some APIs support session IDs that let you resume from a checkpoint, but that’s not universal. Build your retry logic assuming audio can be resent without harm.

Should I use one persistent WebSocket or open a new one per utterance?

One persistent connection is better for latency because it avoids the TLS and handshake overhead per utterance. But it means you need all the lifecycle patterns above. If your use case has long gaps between utterances, closing and reopening can be simpler. The trade-off is about 100 to 300 milliseconds of reconnection latency per utterance.

How does backpressure affect streaming STT differently from streaming TTS?

STT consumes audio and produces text. Dropping audio frames means the model has less context, which reduces accuracy but rarely breaks the output entirely. TTS produces audio from text. Dropping text chunks creates audible gaps or glitches. For STT, err on the side of dropping frames to keep latency low. For TTS, buffer more aggressively to maintain smooth output.

Do all streaming audio APIs support the same WebSocket protocol?

No. Some use raw binary frames with audio data. Others wrap audio in JSON messages with Base64 encoding. Some use Protocol Buffers or custom framing. The patterns in this list are transport-agnostic. They apply at the WebSocket layer regardless of what format the messages take. The application-level protocol (message structure, end-of-stream signaling, heartbeat format) changes per provider.