I run a home automation setup with multiple RTSP IP cameras. The camera dashboard shows a grid of all cameras with a "Stream All" button. Each stream is an MJPEG feed served through ffmpeg via a Python HTTP server.
Click "Stream All" and you'd expect every feed to light up. Instead, 5 or 6 cameras would load and the rest would stay black forever. Refresh, and a different set of cameras would load.
The culprit was well-known: browsers limit ~6 concurrent HTTP/1.1 connections per origin. Each MJPEG stream is a long-lived HTTP response that never closes. Camera 7 has to wait for camera 1 to finish — which is never.
The Solution: WebSocket Multiplexing
WebSocket doesn't have the per-origin connection limit. One WebSocket connection can carry unlimited streams of data. The plan:
- StreamManager — a shared ffmpeg process pool. One ffmpeg per camera, regardless of how many clients are watching. Frames broadcast to all subscribers.
-
/ws/camerasendpoint — clients subscribe to cameras via JSON commands, receive binary JPEG frames with a camera ID prefix. -
Client-side JS — single WebSocket connection, Blob URLs for rendering frames to
<img>elements.
The constraint: stdlib only. No pip dependencies. This is a single-file Python HTTP server running as a macOS LaunchAgent. I implemented the WebSocket protocol (RFC 6455) from scratch — handshake, frame encoding/decoding, ping/pong, binary and text frames.
The frame format
// Server → Client binary frame:
// [1 byte: camera ID length] [N bytes: camera IP] [JPEG bytes]
const buf = new Uint8Array(ev.data);
const idLen = buf[0];
const camId = new TextDecoder().decode(buf.slice(1, 1 + idLen));
const jpeg = buf.slice(1 + idLen);
// Render to <img> via Blob URL
const blob = new Blob([jpeg], {type: 'image/jpeg'});
img.src = URL.createObjectURL(blob);
Python Says: Works Perfectly
I wrote a Python test client that connects over TLS, subscribes to all cameras, and counts frames:
# Subscribe to ALL cameras over single WebSocket
ws_send(sock, json.dumps({
"cmd": "subscribe",
"cameras": camera_ips
}))
# Result after 12 seconds:
# Camera IP Frames
# x.x.x.11 19
# x.x.x.13 20
# x.x.x.16 19
# ... (all cameras streaming)
# TOTAL 209 frames from all cameras
# Total data: ~8 MB in 12s
Every camera streaming. ~8 MB over a single WebSocket in 12 seconds. The StreamManager, ffmpeg pool, frame extraction, binary WebSocket framing — all working perfectly.
Time to open it in a browser.
Browsers Say: No
WebSocket Test
04:46:59.972 Connecting...
04:47:00.148 ERROR: {"isTrusted":true}
04:47:00.148 CLOSED code=1006 reason= clean=false
Code 1006. Abnormal closure. No reason. Not clean. The onopen callback never fires. Both Safari and Brave. Every single time.
The server logs told a different story:
[ws] Client connected from x.x.x.x
[ws] Sending 101 (129 bytes)
[ws] Reader error: ConnectionError: WebSocket connection closed
[ws] Client disconnected, was watching 0 cameras
The server sent the 101 Switching Protocols response. The client connected at the TCP level. But the browser never acknowledged the upgrade. It just... closed.
The Debugging Spiral
What followed was hours of systematically ruling out every possible cause:
Attempt 1: TLS Certificate ❌
Self-signed cert? Generated proper certs with mkcert, installed CA in system keychain. Pages loaded without warnings. WebSocket still failed.
Attempt 2: Buffering ❌
Maybe wfile is buffering the 101? Tried handler.wfile.write() + flush(), then handler.connection.sendall(), then handler.request.sendall(). All sent the bytes. Browser still rejected.
Attempt 3: HTTP Protocol Version ❌
Python's BaseHTTPRequestHandler.protocol_version defaults to "HTTP/1.0". WebSocket requires HTTP/1.1. Set it to "HTTP/1.1". Then built the response manually with raw bytes. Still failed.
Attempt 4: ALPN Negotiation ❌
Maybe the browser is negotiating HTTP/2 via ALPN? Added ctx.set_alpn_protocols(["http/1.1"]). No change.
Attempt 5: Bypass the HTTP handler entirely ❌
Built a standalone raw WebSocket server on a separate port — pure socket, no BaseHTTPRequestHandler. Read the HTTP request manually, send 101 manually. Still failed.
Attempt 6: Mixed content / port issues ❌
Tried ws:// on the HTTP port (Brave auto-upgraded to HTTPS). Tried serving from HTTP. Tried different ports. Nothing.
At this point I had verified the 101 response byte-by-byte:
# Captured via Python, raw bytes from the server:
b'HTTP/1.1 101 Switching Protocols\r\n'
b'Upgrade: websocket\r\n'
b'Connection: Upgrade\r\n'
b'Sec-WebSocket-Accept: MuIAfeA8S6DsJZLE/8a3flJsJzM=\r\n'
b'\r\n'
# 129 bytes. Correct CRLF. Correct headers. Correct format.
# Python clients: works. Browsers: 1006.
The response was byte-for-byte correct. Correct HTTP version. Correct headers. Correct line endings. Correct empty line. And yet every browser on Earth rejected it.
Dad Steps In
I shared the full debugging context with my dad — who happens to run a swarm of AI agents for exactly this kind of problem. His analysis was surgical:
Sec-WebSocket-Accept is not byte-perfect. The computation must use the exact GUID from RFC 6455. Even if the format looks right, if the GUID constant is wrong, the Accept value will be wrong. Python clients don't validate the Accept header. Browsers do. This is the #1 hidden killer.
I looked at my code:
- _WS_MAGIC = b"258EAFA5-E914-47DA-95CA-5AB5F43F86A2"
+ _WS_MAGIC = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
The magic GUID. The one constant that every WebSocket implementation on the planet must agree on. I had it wrong.
Not slightly wrong. Not a typo in one character. The entire last segment was different:
5AB5F43F86A2 ← what I had (WRONG)
C5AB0DC85B11 ← what RFC 6455 specifies (CORRECT)
Why Python Didn't Care
The WebSocket handshake works like this:
- Client sends
Sec-WebSocket-Key: <random base64> - Server concatenates it with the magic GUID
- Server SHA-1 hashes the result, base64 encodes it
- Server sends back
Sec-WebSocket-Accept: <hash> - Client verifies the Accept value matches what it expects
Step 5 is where the divergence happens. Python's WebSocket test clients — and many WebSocket libraries — skip the Accept validation. They see "101 Switching Protocols" and proceed. The Accept header is there but nobody checks it.
Browsers check it. Strictly. Silently. If it doesn't match, they close the TCP connection without sending a close frame — which is why you get code 1006 ("abnormal closure") with no reason string. The browser doesn't even tell you what was wrong.
The Fix: One line. One constant. Changed the GUID to the correct RFC 6455 value. All cameras streaming in every browser instantly.
Lessons Learned
1. Browsers are strict. Clients are lenient.
Don't assume your test client validates what a browser validates. The Sec-WebSocket-Accept header exists specifically so the client can verify the server understood the WebSocket protocol. Python clients being lenient masked a fatal bug for hours.
2. Error code 1006 is useless
1006 means "I closed the connection abnormally." It doesn't say why. It could be a network error, a TLS issue, a protocol violation, or a wrong Accept header. Browser DevTools don't show the specific validation failure. This is a spec decision — 1006 is never sent over the wire, it's generated locally — but it makes debugging nearly impossible.
3. Magic constants are the worst kind of bug
The GUID 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 is an arbitrary string chosen by the RFC authors. It has no structure, no checksum, no way to validate it in isolation. If you copy it wrong, everything looks correct until a strict client rejects it. Use a well-tested library, or copy the constant from the actual RFC text — not from memory.
4. Test with the real consumer
My Python test proved the architecture worked: shared ffmpeg pool, subscriber queues, frame broadcast, binary WebSocket framing. All solid. But I should have tested with a browser from minute one. The gap between "works in Python" and "works in Chrome" was exactly one wrong constant.
5. The architecture was right
Despite the handshake bug, the design held up perfectly once the GUID was fixed:
- All cameras, 1 WebSocket, ~2 fps each
- Shared ffmpeg pool — one process per camera regardless of viewer count
- 10-second grace period on unsubscribe — handles page refresh without killing ffmpeg
- Auto-restart on ffmpeg crash (max 3 in 30s)
- MJPEG fallback for clients that can't do WebSocket
- Zero pip dependencies — pure stdlib Python
The Stack
For anyone building something similar:
# Server: Python stdlib HTTP server with manual WebSocket
# Camera: RTSP → ffmpeg → MJPEG frames
# Transport: WebSocket binary frames (1-byte ID prefix + JPEG)
# Client: Blob URLs → <img> elements
# Infra: macOS LaunchAgent, mkcert TLS
class StreamManager:
# One ffmpeg per camera, broadcast to N subscribers
# subscribe(cam_id, queue) → start ffmpeg if first
# unsubscribe(cam_id, queue) → stop ffmpeg if last (after 10s grace)
# Reader thread: extract JPEGs from ffmpeg stdout
# Queue per client, maxsize=100, drop on full
class CameraWS: # Client-side JavaScript
# Single WebSocket to /ws/cameras
# subscribe([ips]) / unsubscribe([ips])
# onmessage → Blob URL → img.src
# Auto-reconnect, 3-fail MJPEG fallback
The WebSocket approach is the correct way to beat the browser connection limit for multi-camera MJPEG streaming. Just make sure you copy the GUID correctly.
For the record, the WebSocket magic GUID from RFC 6455 Section 4.2.2 is:
258EAFA5-E914-47DA-95CA-C5AB0DC85B11Commit it to memory. Or better yet, don't — use a library.
Top comments (0)