Streaming LLM output to mobile with SSE, and surviving malformed JSON
Why Server-Sent Events are a good fit for LLM-powered apps, and a practical pattern for recovering structured data when a model returns broken JSON.
- LLM
- Streaming
- Reliability
Most LLM features feel slow for one reason: the user waits for the whole response before seeing anything. On mobile networks that wait is even more noticeable. Streaming fixes the perception problem, but it also exposes a second one: models don’t always return valid JSON, and you now have to parse it while it’s still arriving.
Here’s the approach I use when building LLM-backed apps, and the reliability patterns that make it production-ready.
Why SSE rather than WebSockets
For LLM output, traffic flows one way: server to client. That makes Server-Sent Events (SSE) a natural fit:
- It’s plain HTTP, so it works with your existing auth, proxies, load balancers and CDN rules.
- The browser (and most mobile HTTP clients) handle framing and reconnection for you.
- There’s no connection-upgrade handshake or bidirectional protocol to secure and operate.
Reach for WebSockets when you genuinely need low-latency two-way messaging. For “stream this answer to the screen,” SSE is simpler and easier to run.
A minimal streaming endpoint
The shape of the server is the same in any framework. This sketch uses Fastify’s raw response:
app.post('/api/generate', async (req, reply) => {
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
const controller = new AbortController();
req.raw.on('close', () => controller.abort()); // stop paying for tokens nobody reads
const heartbeat = setInterval(() => reply.raw.write(': ping\n\n'), 15_000);
try {
for await (const chunk of llm.stream(req.body, { signal: controller.signal })) {
reply.raw.write(`data: ${JSON.stringify({ delta: chunk })}\n\n`);
}
reply.raw.write('event: done\ndata: {}\n\n');
} catch (err) {
reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: 'generation_failed' })}\n\n`);
} finally {
clearInterval(heartbeat);
reply.raw.end();
}
});
Three details matter more than they look: abort on disconnect (so a closed app stops costing money), a heartbeat (so idle proxies don’t drop the connection) and an explicit done/error event (so the client never has to guess how a stream ended).
The harder problem: broken JSON
If you ask a model for structured output (a quiz question, a list of topics, a form definition), you’ll eventually receive something like this mid-stream:
{"questions":[{"prompt":"What does CAP stand for?","options":["Consistency","Avail
A strict JSON.parse throws, and a truncated or slightly malformed final response throws just as hard. A resilient pipeline treats model output as untrusted, partial input:
- Accumulate chunks into a buffer.
- Try a strict parse first. Most responses are fine.
- Fall back to repair: close any open strings, arrays and objects, drop a dangling comma or colon, and parse again.
- Emit completed items early. If the array has two finished objects and a third in progress, show the two.
- Keep the last good state so a bad tail never wipes what the user already sees.
A small repair function covers most real-world cases:
export function repairPartialJson(input: string): unknown | undefined {
const closers: string[] = [];
let inString = false;
let escaped = false;
for (const ch of input) {
if (inString) {
if (escaped) escaped = false;
else if (ch === '\\') escaped = true;
else if (ch === '"') inString = false;
continue;
}
if (ch === '"') inString = true;
else if (ch === '{') closers.push('}');
else if (ch === '[') closers.push(']');
else if (ch === '}' || ch === ']') closers.pop();
}
let repaired = input;
if (inString) repaired += '"';
repaired = repaired.replace(/[,:]\s*$/, (m) => (m.startsWith(':') ? ': null' : '')); // dangling comma / colon
repaired += closers.reverse().join('');
try {
return JSON.parse(repaired);
} catch {
return undefined; // caller keeps the last good value
}
}
It’s deliberately conservative: when repair can’t produce valid JSON, it returns undefined and the caller keeps the previous good state.
Reliability checklist
- Provider-agnostic interface. Hide the model vendor behind one
stream()contract so you can swap models, add fallbacks or A/B test without touching product code. - Timeouts and retries with exponential backoff on the model call, but only before the first byte is sent to the user.
- Log raw model output (with appropriate redaction) so you can reproduce parsing failures instead of guessing.
- Validate after repairing. Repaired JSON is still only syntactically valid; check it against a schema before trusting it.
- Protect the endpoint: authentication, rate limiting and per-user quotas, because streaming endpoints are expensive to abuse.
None of this is exotic, but together it’s the difference between a demo that wows once and a feature people trust every day. If you’re taking an LLM prototype to production and want a second pair of eyes on the architecture, get in touch.