An eventsource browser chat streaming client is the simplest way to render token-by-token LLM output in a web app, but the browser’s EventSource API only speaks GET. This tutorial builds a minimal client and a tiny server that streams chat completions over SSE so you can see exactly what the wire format looks like and how to handle reconnection, errors, and JSON framing in practice.
Prerequisites
- Node.js 18+ (built-in
httpand globalfetchare sufficient) - A modern browser (Chrome, Firefox, or Safari)
- Basic familiarity with ES modules and HTTP headers
- Optional: an OpenAI-compatible streaming endpoint. If you use a gateway such as n4n.ai, its OpenAI-compatible endpoint emits SSE when
stream:trueis set, butEventSourcecannot issue the POST those endpoints require, so we will wrap it behind a local bridge.
You do not need a framework. We will write plain .mjs and .js files.
Step 1: A minimal SSE server
Most chat APIs expect POST, but EventSource is GET-only. To teach the client honestly, we first stand up a server that speaks real SSE on GET.
Create server.mjs:
import http from 'node:http';
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname !== '/chat') {
res.writeHead(404);
res.end();
return;
}
const prompt = url.searchParams.get('prompt') ?? 'hello';
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*',
});
const tokens = `Echo: ${prompt}`.split('');
let i = 0;
const timer = setInterval(() => {
if (i >= tokens.length) {
res.write('event: done\ndata: {}\n\n');
clearInterval(timer);
res.end();
return;
}
res.write(`id: ${i}\nevent: token\ndata: ${JSON.stringify({ token: tokens[i] })}\n\n`);
i++;
}, 50);
req.on('close', () => clearInterval(timer));
});
server.listen(3000, () => console.log('SSE server on http://localhost:3000'));
Run it:
node server.mjs
Verify the raw stream with curl before touching the browser:
curl -N "http://localhost:3000/chat?prompt=hi"
Expected output (truncated):
id: 0
event: token
data: {"token":"E"}
id: 1
event: token
data: {"token":"c"}
id: 2
event: token
data: {"token":"h"}
event: done
data: {}
If you see those frames, the server is correct.
Step 2: The eventsource browser chat streaming client
Create index.html:
<!doctype html>
<html lang="en">
<body>
<input id="prompt" value="hello world" size="40">
<button id="send">Send</button>
<pre id="out"></pre>
<script type="module" src="./client.js"></script>
</body>
</html>
Now client.js. This is the core of our eventsource browser chat streaming client:
const out = document.getElementById('out');
const promptInput = document.getElementById('prompt');
const sendBtn = document.getElementById('send');
let es = null;
function connect(prompt) {
if (es) es.close();
out.textContent = '';
const url = `http://localhost:3000/chat?prompt=${encodeURIComponent(prompt)}`;
es = new EventSource(url);
es.addEventListener('token', (e) => {
try {
const data = JSON.parse(e.data);
if (data.token) out.textContent += data.token;
} catch (err) {
console.warn('malformed frame', e.data);
}
});
es.addEventListener('done', () => {
out.textContent += '\n[stream complete]\n';
es.close();
});
es.onerror = (err) => {
console.error('EventSource error', err);
es.close();
};
}
sendBtn.onclick = () => connect(promptInput.value);
Serve the folder over HTTP (file:// breaks module loading and CORS):
npx serve .
Open the page, type a prompt, click Send. Characters should append one by one. The network tab will show a single pending request emitting text/event-stream frames.
Step 3: Reconnection and event IDs
The browser auto-reconnects EventSource after a connection drop, and if the server sent id: fields, the browser sends Last-Event-ID on reconnect. Our server already emits id: ${i}. To make the client resilient, listen for token (already done) and rely on the default 3‑second retry unless you send a retry: field.
Add a retry hint to the server frame if you want faster recovery:
res.write(`retry: 1000\nid: ${i}\nevent: token\ndata: ${JSON.stringify({ token: tokens[i] })}\n\n`);
In the client, you can log reconnects by watching onerror and re-creating the source only if you need custom logic. For most chat UIs, closing on done and letting the user re-click is cleaner than silent retries mid-conversation.
Step 4: Bridging POST-only LLM endpoints
A real chat model call is a POST with a JSON body. Since the eventsource browser chat streaming client cannot POST, you put a tiny bridge in front. Below is a minimal Node bridge that accepts GET from the browser and forwards a POST to any OpenAI-compatible endpoint, piping the upstream SSE straight through.
import http from 'node:http';
const UPSTREAM = process.env.UPSTREAM_URL ?? 'https://api.example.com/v1/chat/completions';
const KEY = process.env.API_KEY ?? '';
const bridge = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (!url.pathname.startsWith('/bridge')) {
res.writeHead(404); res.end(); return;
}
const prompt = url.searchParams.get('prompt') ?? '';
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
});
const upstream = await fetch(UPSTREAM, {
method: 'POST',
headers: {
'Authorization': `Bearer ${KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: prompt }],
}),
});
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(decoder.decode(value, { stream: true }));
}
res.end();
});
bridge.listen(4000, () => console.log('Bridge on :4000'));
Point the client at http://localhost:4000/bridge?prompt=... instead of :3000/chat. The upstream will emit OpenAI-style data: {choices:[{delta:{content:"..."}}]} frames; parse them accordingly:
es.addEventListener('message', (e) => {
if (e.data === '[DONE]') return;
const json = JSON.parse(e.data);
const delta = json.choices?.[0]?.delta?.content;
if (delta) out.textContent += delta;
});
Step 5: Production concerns
CORS: The bridge must return Access-Control-Allow-Origin matching your app domain. Wildcard is fine for local dev only.
Auth: Never ship the API key to the browser. The bridge holds it server-side.
Parsing: Always wrap JSON.parse in try/catch. Upstreams occasionally send comments (: ping) or heartbeats.
Timeouts: EventSource has no client-side timeout. If your bridge dies silently, add a server-side setTimeout that writes a comment frame every 15s to keep the connection classified as alive by proxies.
Backpressure: Browsers handle SSE rendering fine, but if you append thousands of nodes, use textContent += on a single <pre> rather than creating DOM nodes per token.
Expected full-roundtrip output
Browser <pre> after streaming “hello world” from the local server:
Echo: hello world
[stream complete]
From a real model via the bridge, you would see the model’s reply token-by-token instead of the echo.
Closing notes on the eventsource browser chat streaming client
You now have a working eventsource browser chat streaming client backed by a controllable SSE server and a POST bridge for real LLMs. The pattern scales to any OpenAI-compatible gateway: keep EventSource confined to GET, never leak keys, and treat every frame as untrusted JSON. For higher concurrency, replace the Node http bridge with a streaming-capable reverse proxy that already understands SSE, but the client code stays identical.