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

Three data lines become one string Three consecutive data field lines being joined with newline characters into the single string the client receives, with no trailing newline. Three data lines become one string data: line one / data: line two / data: line three data: { first line data: "a": 1 joined with \n data: } joined with \n ⏎ dispatch e.data = the joined string
The parser joins repeated data lines with a single newline and adds none at the end β€” so a stray blank line does not pad the payload, it dispatches the event.

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

How should this payload cross the wire? Decision tree choosing between escaping newlines inside one data line and splitting a payload across repeated data lines. How should this payload cross the wire? Is the payload JSON? One compact line JSON.stringify escapes \n yes Repeat data: client rejoins with \n no, multi-line text Base64 first never raw bytes binary
For JSON the answer is almost always a single compact line: the serializer escapes newlines for you and the client parses one string with no reassembly.

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

Confirming the frame terminators byte by byte Verification sequence piping the stream through hexdump to confirm single newlines between data lines and a double newline at the event boundary. Confirming the frame terminators byte by byte curl -N hexdump -C You pipe the raw stream 64 61 74 61 3a … 0a (one line) … 0a 0a (event boundary) no stray 0d anywhere
0a 0a at the boundary and single 0a between data lines is the whole contract β€” if a 0d appears where you did not put one, something in the path rewrote your frames.

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.