Sending Binary Data over SSE Permalink to this section
Part of Understanding the Event Stream Format, under SSE Protocol Fundamentals & Architecture.
The event stream format is UTF-8 text by definition. There is no binary frame type, no length prefix and no way to put an arbitrary byte sequence into a data: field — a zero byte, a stray carriage return or an invalid UTF-8 sequence would either break framing or be replaced by the decoder. Yet streams regularly need to carry binary things: thumbnails, audio snippets, protobuf messages, compressed deltas, cryptographic signatures. This guide covers the three ways to do it and when each is right.
Symptom & Developer Intent Permalink to this section
- Images or protobuf payloads sent through a stream arrive corrupted, with
�replacement characters. - Some events are split in two or disappear entirely after binary content is added.
- A stream that carries small binary blobs uses far more bandwidth than expected.
- Parsing base64 on the client makes a busy stream stutter on low-end phones.
The intent is to carry binary content through an SSE stream without corruption, at an acceptable size and CPU cost — or to recognise when it should not travel through the stream at all.
Root Cause Analysis Permalink to this section
Two properties of the format rule out raw bytes. First, the stream is decoded as UTF-8, and byte sequences that are not valid UTF-8 are replaced with U+FFFD, destroying the original bytes. Second, the bytes CR (0x0D) and LF (0x0A) terminate lines, so any occurrence inside a payload ends the field early and can dispatch a partial event.
So binary must be converted to text that is valid UTF-8 and contains no line breaks. Base64 does exactly that, at a cost of 4 output bytes for every 3 input bytes.
Step-by-Step Resolution Permalink to this section
Step 1 — Encode binary as base64 on the server Permalink to this section
// Node.js: base64 contains only A–Z a–z 0–9 + / =, so it is safe on one data line.
function binaryEvent(id, type, bytes, meta = {}) {
const payload = JSON.stringify({ ...meta, b64: Buffer.from(bytes).toString('base64') });
return `id: ${id}\nevent: ${type}\ndata: ${payload}\n\n`;
}
# Python
import base64, json
def binary_event(id, type, data: bytes, **meta):
payload = json.dumps({**meta, "b64": base64.b64encode(data).decode("ascii")})
return f"id: {id}\nevent: {type}\ndata: {payload}\n\n"
Wrapping the base64 string in JSON leaves room for metadata — content type, dimensions, sequence — without inventing a second framing layer. Base64url (- and _ instead of + and /) is equally safe; choose whichever your decoders expect.
Step 2 — Decode efficiently on the client Permalink to this section
es.addEventListener('thumb', (e) => {
const { b64, mime } = JSON.parse(e.data);
// Modern browsers: Uint8Array.fromBase64; fall back to atob for older ones.
const bytes = Uint8Array.fromBase64
? Uint8Array.fromBase64(b64)
: Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
img.src = url; // revoke the previous URL to avoid leaks
});
For high-rate binary streams, decode in a Web Worker and transfer the resulting ArrayBuffer to the page, so the main thread only renders.
Step 3 — Let transport compression recover the overhead where possible Permalink to this section
Base64 inflates payloads by about 33 %. HTTP compression recovers part of it when the underlying bytes are compressible, but compressing an event stream requires flushing the compressor after every event, and already-compressed binaries (JPEG, PNG, zipped protobuf) gain almost nothing.
Step 4 — Send a reference instead of the bytes when payloads are large Permalink to this section
For anything larger than a few tens of kilobytes, the better design is usually to stream a pointer and fetch the bytes over an ordinary HTTP request:
event: thumbnail-ready
data: {"id":"img_81","url":"/media/img_81.jpg","etag":"\"8f1c\"","bytes":184233}
The client fetches the URL, which the browser caches, can resume, can decode off the main thread, and can deliver over HTTP/2 or HTTP/3 in parallel with the stream. The stream stays small and responsive, and a slow download never delays the next event.
When streaming references, make them safe to fetch: sign the URL or require the same session cookie as the stream, give the resource a stable ETag so repeated references are served from cache, and include enough metadata in the event (size, type, dimensions) for the interface to reserve space before the bytes arrive. If many clients receive the same reference at once — a new image in a shared room — put the object behind a CDN so the fetch wave does not land on your origin.
Step 5 — Consider a different transport for continuous binary data Permalink to this section
Audio, video frames and high-rate binary telemetry are better served by transports with binary framing: WebSockets, WebTransport, or media-specific protocols. SSE remains a good control channel alongside them — announcing what to fetch or which track to play — as discussed in SSE vs WebTransport.
Validation & Monitoring Permalink to this section
# Round-trip test: send a file through the stream and compare hashes.
sha256sum sample.bin
curl -sN http://localhost:3000/debug/binary?file=sample.bin \
| grep -m1 '^data:' | sed 's/^data: //' | jq -r .b64 | base64 -d | sha256sum
Unit-test the encoder with random byte arrays, including arrays containing 0x00, 0x0A, 0x0D and invalid UTF-8 sequences, and assert that decode(encode(bytes)) equals the original — the round-trip approach from unit testing event stream serialization. In production, track payload sizes per event type; a type whose p95 creeps past the inline threshold should move to references.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Can I send binary data with a different charset in the Content-Type?
No. The event stream is always decoded as UTF-8 regardless of any charset parameter, so binary cannot be smuggled through a different encoding declaration.
Is base64url better than base64 for SSE?
Both are safe in a data field. Base64url avoids + and /, which matters only if the same string is reused in URLs. Pick one and use it consistently on both ends.
Does HTTP compression remove base64's overhead?
Partially, for compressible formats, if the stream is compressed and flushed per event. Already-compressed media stays about a third larger.
What size is too large to send inline?
There is no protocol limit, but large events delay everything behind them and are parsed on the main thread. Above roughly 16 to 64 KB, a URL reference is usually the better choice.