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.

What happens to raw bytes in a data field Stack showing a binary payload passing through the UTF-8 decoder, where invalid sequences become replacement characters, and the line splitter, where embedded CR and LF bytes cut the event. What happens to raw bytes in a data field Raw binary payload 0x89 0x50 0x0A 0xFF … arbitrary bytes UTF-8 decoder invalid sequences become U+FFFD Line splitter 0x0A, 0x0D end the field early Dispatched data truncated, altered not the original bytes
Either stage alone corrupts the payload. Encoding the bytes as text avoids both.

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.

Bytes on the wire for a 30 KB binary payload Bar chart comparing wire size for a thirty kilobyte payload as raw bytes over another transport, as base64 over SSE, and as base64 over SSE with per-event gzip, for compressible and incompressible content. Bytes on the wire for a 30 KB binary payload Raw bytes (for comparison) 30 KB base64, incompressible JPEG 40 KB base64 + gzip, JPEG ~39 KB base64 + gzip, protobuf ~22 KB kilobytes on the wire per event
Base64's third is a real cost for already-compressed media. For compressible binary formats, per-event compression takes most of it back.

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.

Choosing how to deliver binary content Decision tree for delivering binary data alongside an SSE stream, leading to inline base64, a URL reference, or a different transport. Choosing how to deliver binary content Payload under ~16 KB and needed instantly? Inline base64 in JSON yes no Payload larger, or cacheable? Stream a URL, fetch it yes no Continuous binary media or high rate? Use a binary transport yes no Inline base64, measure
Inline base64 is for small, time-critical payloads. Everything larger belongs behind a URL.

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.