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.datais empty ("") β the blank line you embedded ended the event before any data line was flushed.event.datacontains only the text up to the first\nβ the subsequentdata:fields were silently dropped.JSON.parse(event.data)throwsSyntaxError: 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:
- Read lines until a blank line (
\n\n,\r\r, or\r\n\r\n). - For each non-blank line, split on the first
:to get a field name and value. - If the field name is
data, append the value, then append\nto the eventβsdatabuffer. - On blank line: trim the trailing
\nfrom the buffer, fire themessageevent.
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)(nospaceargument) 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-rolldata:prefixes at call sites. - Add
X-Accel-Buffering: no(nginx) andCache-Control: no-cacheto 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\ncomment 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 -Aagainst 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.