Formatting Multi-Line data Fields in SSE Permalink to this section

Part of Understanding the Event Stream Format.

The text/event-stream wire format is newline-delimited: a blank line terminates each event, and a literal \n inside a field value would be mis-parsed as the end of that field β€” or the end of the event itself. This is the root cause of the most common SSE payload bug: embedding a JSON object that contains newlines (formatted output, template strings, or newline-escaped text) and finding that the client receives a truncated or empty event.data string.

This guide covers exactly how the SSE protocol specification handles multi-line data, how your server must serialize it, and how the browser’s EventSource object reassembles the lines β€” with copy-paste server code for Node.js, Python, and Go, plus a validation workflow.


Symptom & Developer Intent Permalink to this section

You are sending an SSE event whose payload is a multi-line string β€” typically a pretty-printed JSON object, a stack trace, a template-rendered HTML fragment, or a chunk from an LLM that contains a newline character. The client receives one of these outcomes:

  • event.data is empty ("") β€” the blank line you embedded ended the event before any data line was flushed.
  • event.data contains only the text up to the first \n β€” the subsequent data: fields were silently dropped.
  • JSON.parse(event.data) throws SyntaxError: Unexpected end of JSON input β€” the JSON was split mid-object.
  • The stream silently hangs after the malformed event, because the parser is now mis-aligned.

The developer intent is to deliver an arbitrary multi-line string or a structured JSON payload β€” intact, with newlines preserved β€” to the EventSource listener.


Root Cause Analysis Permalink to this section

How the parser sees newlines Permalink to this section

The WHATWG spec defines the text/event-stream stream as a sequence of UTF-8 lines separated by U+000A LINE FEED (\n), U+000D CARRIAGE RETURN (\r), or \r\n. Every one of those sequences is a field separator, regardless of context. There is no escaping mechanism for literal newlines inside a field value.

The dispatch algorithm works as follows:

  1. Read lines until a blank line (\n\n, \r\r, or \r\n\r\n).
  2. For each non-blank line, split on the first : to get a field name and value.
  3. If the field name is data, append the value, then append \n to the event’s data buffer.
  4. On blank line: trim the trailing \n from the buffer, fire the message event.

That append-then-newline rule in step 3 is the mechanism that allows multi-line data. Multiple data: lines are concatenated with \n between them β€” which is exactly the newline character the original payload contained. The spec is therefore the solution: one literal newline in the payload β†’ one extra data: line in the wire frame.

What goes wrong when you skip this Permalink to this section

If your server does res.write("data: " + JSON.stringify(obj) + "\n\n") and obj is:

{
  "message": "hello\nworld"
}

The wire frame contains:

data: {
data:   "message": "hello
data: world"
data: }

Wait β€” that is actually almost correct syntax if every line starts with data: . The problem occurs when you pass a raw multi-line string without prefixing each line. The typical mistake:

// WRONG β€” embeds a bare newline inside a single data: field
res.write(`data: ${JSON.stringify(obj, null, 2)}\n\n`);

Produces on the wire (JS template literal preserves the newlines):

data: {
  "message": "hello\nworld"
}

The second line "message": ... has no data: prefix. The parser sees it as an unknown field name (everything before the first :) and ignores it. The third and fourth lines are similar. The blank line terminates the event with only the first fragment in the buffer.


Step-by-Step Resolution Permalink to this section

Step 1 β€” Split the payload on newlines and prefix each line Permalink to this section

The canonical fix is a one-liner helper that converts every \n into \ndata: . Apply it to any string you want to send as a data field value.

Node.js / JavaScript

// utils/sse.js
/**
 * Serialize a value as one or more `data:` lines.
 * Handles embedded newlines per WHATWG SSE spec Β§9.2.6.
 * @param {string} value - raw string payload (may contain \n)
 * @returns {string} wire-format data lines, NOT including the trailing blank line
 */
function dataLines(value) {
  // Replace every \n with \ndata: so each logical line gets its own field
  return "data: " + value.replace(/\n/g, "\ndata: ");
}

// Sending a structured JSON object (compact β€” no embedded newlines):
function sendEvent(res, payload, eventType = null, id = null) {
  let frame = "";
  if (id != null)        frame += `id: ${id}\n`;
  if (eventType != null) frame += `event: ${eventType}\n`;
  frame += dataLines(JSON.stringify(payload)); // compact JSON: no \n inside
  frame += "\n\n"; // blank line terminates the event
  res.write(frame);
}

// Sending a string that may contain real newlines (e.g. LLM chunk, stack trace):
function sendRawText(res, text) {
  res.write(dataLines(text) + "\n\n");
}

Step 2 β€” Never pretty-print JSON in the payload Permalink to this section

Compact JSON (JSON.stringify(obj) with no replacer/space arguments) produces a single-line string with no literal newlines. Embedded string values that happen to contain \n are JSON-escaped to \\n, not actual newlines, so they pass through dataLines() safely.

// GOOD β€” compact, single line, no embedded \n
const frame = dataLines(JSON.stringify({ msg: "hello\nworld" })) + "\n\n";
// Wire: data: {"msg":"hello\\nworld"}\n\n
// client: JSON.parse(event.data).msg === "hello\nworld"  βœ“

// BAD β€” pretty-printed, literal newlines break the frame
const frame = dataLines(JSON.stringify({ msg: "hello\nworld" }, null, 2)) + "\n\n";
// Wire: data: {\ndata:   "msg": "hello\\nworld"\ndata: }\n\n
// Client reconstructed string: '{\n  "msg": "hello\\nworld"\n}'
// JSON.parse still works here, BUT only because dataLines() handled the \n.
// Skip dataLines() and the frame is corrupt.

Pretty-printed JSON is parseable when dataLines() is applied correctly, but it wastes bytes (see Maximum Payload Size Limits for SSE Streams). Keep payloads compact.

Step 3 β€” Implement the same helper in Python (FastAPI / Starlette) Permalink to this section

# sse_utils.py
import json
from typing import Any, Optional

def data_lines(value: str) -> str:
    """Convert a (possibly multi-line) string into prefixed data: lines."""
    return "data: " + value.replace("\n", "\ndata: ")

def sse_frame(
    payload: Any,
    event: Optional[str] = None,
    event_id: Optional[str] = None,
) -> str:
    """
    Build a complete SSE event frame.
    payload is serialized with compact JSON (no indent).
    """
    parts: list[str] = []
    if event_id is not None:
        parts.append(f"id: {event_id}")
    if event is not None:
        parts.append(f"event: {event}")
    parts.append(data_lines(json.dumps(payload, separators=(",", ":"))))
    return "\n".join(parts) + "\n\n"

# FastAPI streaming endpoint
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

async def event_generator():
    for i in range(5):
        payload = {"seq": i, "note": "line one\nline two"}
        yield sse_frame(payload, event="update", event_id=str(i))

@app.get("/stream")
async def stream():
    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",   # defeat nginx proxy buffering
        },
    )

The separators=(",", ":") argument removes all whitespace from the JSON output, preventing any embedded newlines from pretty-printing.

Step 4 β€” Implement in Go using fmt.Fprintf per line Permalink to this section

// ssewriter/ssewriter.go
package ssewriter

import (
    "fmt"
    "net/http"
    "strings"
)

// WriteEvent sends a complete SSE event to w, splitting payload on \n.
// eventType and id may be empty strings to omit those fields.
func WriteEvent(w http.ResponseWriter, payload, eventType, id string) error {
    if id != "" {
        if _, err := fmt.Fprintf(w, "id: %s\n", id); err != nil {
            return err
        }
    }
    if eventType != "" {
        if _, err := fmt.Fprintf(w, "event: %s\n", eventType); err != nil {
            return err
        }
    }
    // Split on \n and emit one data: line per segment
    lines := strings.Split(payload, "\n")
    for _, line := range lines {
        if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil {
            return err
        }
    }
    // Blank line terminates the event
    if _, err := fmt.Fprint(w, "\n"); err != nil {
        return err
    }
    // Flush to the client immediately
    if f, ok := w.(http.Flusher); ok {
        f.Flush()
    }
    return nil
}

strings.Split("hello\nworld", "\n") produces ["hello", "world"], so fmt.Fprintf emits:

data: hello
data: world

The EventSource parser joins them with \n, restoring the original string.

Step 5 β€” Reassemble on the client Permalink to this section

No special client code is needed for the plain-string case β€” event.data already contains the correctly joined string.

// browser client β€” works for both single-line and multi-line payloads
const es = new EventSource("/stream");

es.addEventListener("update", (e) => {
  // e.data is the fully reassembled string, \n intact
  const obj = JSON.parse(e.data);   // safe for compact JSON
  console.log(obj.note);            // "line one\nline two"
});

es.onerror = (err) => {
  // Log but let EventSource auto-reconnect per retry: interval
  console.error("SSE error", err);
};

For error handling and reconnection UX, wrap the parse call in a try/catch and emit a fallback state when JSON.parse throws β€” this catches any residual formatting bugs during rollout.


Validation & Monitoring Permalink to this section

curl inspection Permalink to this section

# Pipe raw bytes through cat -A to see \n vs \r\n
curl -sN -H "Accept: text/event-stream" https://your-api/stream | cat -A | head -40

A correctly formatted multi-line event looks like:

id: 42$
event: update$
data: {"seq":0,"note":"line one$
data: line two"}$
$

Each line ends with $ (cat -A’s \n marker). A blank line ($ alone) ends the event. If you see a line that does not start with data: , id: , event: , retry: , or : (comment), that line is being silently discarded by every spec-compliant parser.

Browser DevTools Permalink to this section

Open Network β†’ EventStream (Chrome/Edge) or Network β†’ Response (Firefox). Select your SSE request. The β€œEventStream” tab shows each parsed event; expand the data field to see the fully reassembled value. If the multi-line payload is intact, you will see the newlines rendered as line breaks in the preview.

Unit test stub (Node.js) Permalink to this section

// test/sse-multiline.test.mjs
import assert from "node:assert/strict";
import { dataLines } from "../utils/sse.js";

// Spec Β§9.2.6: parser appends \n after each data: value, then strips trailing \n
function simulateParse(frame) {
  const buffer = [];
  for (const line of frame.split("\n")) {
    if (line.startsWith("data: ")) buffer.push(line.slice(6));
    else if (line === "") break; // blank line = end of event
  }
  return buffer.join("\n"); // spec: join with \n, then strip trailing (already done)
}

const original = "hello\nworld\nthird line";
const frame = dataLines(original);
const reassembled = simulateParse(frame);

assert.equal(reassembled, original, "multi-line round-trip must be lossless");
console.log("PASS");

Run with node --test test/sse-multiline.test.mjs or any Node 18+ test runner.


Wire Format Reference Permalink to this section

Scenario Server writes event.data received
Single-line string data: hello world\n\n "hello world"
Two-line string data: line1\ndata: line2\n\n "line1\nline2"
JSON with escaped \n data: {"k":"a\\nb"}\n\n parsed: {k: "a\nb"}
JSON pretty-printed (no data: prefix per line) data: {\n "k": 1\n}\n\n "{" β€” broken
JSON pretty-printed (prefixed) data: {\ndata: "k": 1\ndata: }\n\n '{\n "k": 1\n}' β€” parseable
Empty payload line data: \n\n ""
Comment between data lines data: a\n: comment\ndata: b\n\n "a\nb" (comments ignored)

The event ID and retry mechanism fields (id:, retry:) must appear as their own lines and are never part of the data buffer. Mixing them inside a data: value (by embedding a literal id: prefix through a newline) would register as a legitimate id: field β€” another reason to keep payload serialization strict.

⚑ Production Directives

  • Always serialize JSON with JSON.stringify(obj) (no space argument) before passing to your SSE frame builder β€” compact output eliminates embedded newlines at the source.
  • Centralize SSE framing in a single writeEvent() / sse_frame() helper; never hand-roll data: prefixes at call sites.
  • Add X-Accel-Buffering: no (nginx) and Cache-Control: no-cache to every SSE response β€” proxy buffering reassembles chunks and can mask multi-line bugs until a payload exceeds the buffer size.
  • Emit a regular : ping\n\n comment heartbeat every 15–30 s so that load balancers and mobile networks do not close idle connections before a large multi-line payload finishes writing.
  • In staging, run curl -sN … | cat -A against every SSE endpoint and assert that every non-blank line starts with a known field prefix.

Verification Checklist Permalink to this section


Frequently Asked Questions Permalink to this section

Can I send a truly empty data field?

Yes. data: \n\n (a data: field with an empty value) dispatches an event whose event.data is "". Use it as a keep-alive ping only if you want the client's onmessage handler to fire; use the comment syntax (: ping\n\n) if you want a heartbeat that does NOT trigger any handler.

Does \r\n line ending change the multi-line behavior?

No. The WHATWG spec treats \r, \n, and \r\n identically as line terminators. A blank line is two consecutive terminators of any combination. In practice, use \n throughout β€” some proxy implementations mishandle bare \r.

What happens if a data: line contains a colon?

The spec splits only on the first colon. A line like data: {"url":"https://example.com"} is parsed correctly β€” the field name is data and the value is {"url":"https://example.com"}. Only the first colon is the delimiter.

Can I send binary data in a data: field?

SSE is a text protocol (text/event-stream is a UTF-8 stream). Binary data must be Base64-encoded before embedding in a data: field. Base64 output contains no newlines unless you insert line breaks every 76 characters (as MIME does) β€” if you use a line-wrapped encoder, you must still prefix each line with data: .

Do I need to handle \r\n inside the payload differently?

Yes. The spec normalizes \r\n to a single line terminator. If your payload contains \r\n (Windows line endings), a spec-compliant parser will treat it as a single newline β€” the same as \n. Your split-and-prefix logic should therefore split on /\r\n|\r|\n/ rather than just \n to avoid emitting a bare \r inside a data: value, which some parsers may mis-handle.