This streaming chat ui sse fetch tutorial walks through building a minimal but production-minded chat interface that renders LLM output token-by-token. We’ll use the browser fetch() API to post to a small Node proxy and parse the Server-Sent Events (SSE) stream the model returns, no heavy frameworks required.
Prerequisites
- Node.js 18+ (global
fetchavailable) - A package manager (npm)
- Basic familiarity with HTML, ES modules, and async/await
- An OpenAI-compatible endpoint and key. If you’re using n4n.ai, the single OpenAI-compatible endpoint fronts 240+ models with automatic fallback, but the client code here is identical.
curlfor a quick sanity check
Scaffold the server
Create a directory and install Express:
mkdir sse-chat && cd sse-chat
npm init -y
npm install express
The server’s only job is to accept a POST from the browser, forward it to the model provider with stream: true, and pipe the response body back. This avoids CORS and keeps your key server-side.
// server.js
import express from 'express';
const app = express();
app.use(express.json());
const MODEL_ENDPOINT = process.env.MODEL_ENDPOINT
|| 'https://api.openai.com/v1/chat/completions';
const API_KEY = process.env.API_KEY;
app.post('/chat', async (req, res) => {
const { messages } = req.body;
if (!Array.isArray(messages)) {
res.status(400).json({ error: 'messages array required' });
return;
}
const upstream = await fetch(MODEL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: req.body.model || 'gpt-3.5-turbo',
messages,
stream: true,
}),
});
// Forward status and SSE content type
res.status(upstream.status);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Pipe the upstream SSE bytes straight to the browser
const reader = upstream.body.getReader();
const pump = async () => {
const { done, value } = await reader.read();
if (done) { res.end(); return; }
res.write(Buffer.from(value));
pump();
};
pump();
});
app.use(express.static('public'));
app.listen(3000, () => console.log('on :3000'));
Run it with API_KEY=sk-... node server.js. Test the stream with curl before touching the browser:
curl -N -X POST localhost:3000/chat \
-H 'Content-Type: application/json' \
-d '{"messages":[{"role":"user","content":"Say hi in 5 words"}]}'
Expected output is a series of data: {...} lines ending with data: [DONE].
Build the chat UI
Create public/index.html. Keep it plain:
<!doctype html>
<html>
<head><meta charset="utf-8"><title>SSE Chat</title></head>
<body>
<div id="log"></div>
<textarea id="input" rows="3" placeholder="Message"></textarea>
<button id="send">Send</button>
<script type="module" src="client.js"></script>
</body>
</html>
Client: fetch and SSE parsing
The core of this streaming chat ui sse fetch tutorial is reading the chunked response and splitting it into SSE frames. Browsers don’t auto-parse SSE inside fetch; you get raw bytes.
// public/client.js
const log = document.getElementById('log');
const input = document.getElementById('input');
const send = document.getElementById('send');
let controller = null;
send.addEventListener('click', async () => {
const text = input.value.trim();
if (!text) return;
input.value = '';
appendMessage('user', text);
const messages = [{ role: 'user', content: text }];
controller = new AbortController();
const res = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal: controller.signal,
});
if (!res.ok) {
appendMessage('error', `HTTP ${res.status}`);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let assistantText = '';
const msgEl = appendMessage('assistant', '');
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by double newlines
const frames = buffer.split('\n\n');
buffer = frames.pop() || '';
for (const frame of frames) {
const line = frame.trim();
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') continue;
try {
const json = JSON.parse(payload);
const token = json.choices?.[0]?.delta?.content || '';
assistantText += token;
msgEl.textContent = assistantText;
} catch (e) {
console.warn('bad json', payload);
}
}
}
});
function appendMessage(role, text) {
const el = document.createElement('div');
el.className = `msg ${role}`;
el.textContent = text;
log.appendChild(el);
return el;
}
Why manual SSE parsing
EventSource only issues GET requests and can’t send a JSON body. Since chat needs a POST with the conversation history, fetch() is the correct tool. The wire format is still SSE, so we split on \n\n and strip data: .
Handle abort and errors
Users expect a stop button. Wire the AbortController to a second button:
// add to client.js
const stop = document.createElement('button');
stop.textContent = 'Stop';
stop.id = 'stop';
document.body.appendChild(stop);
stop.addEventListener('click', () => controller?.abort());
On abort, reader.read() rejects; wrap the loop in try/catch and show a partial message. Network errors and non-200 responses should also append an error line. The server pipe will end naturally when upstream closes.
Checkpoint: expected browser behavior
Load http://localhost:3000. Type “Explain SSE in one sentence”. Click Send. You should see a new user bubble, then an assistant bubble that grows word-by-word without a full-page refresh. The Network tab shows a single POST with text/event-stream response and fluctuating received bytes.
If you used a gateway such as n4n.ai, the same code benefits from provider fallback when a model is rate-limited; the client only sees a continuous SSE stream.
Production considerations
- Buffer boundaries: A
data:frame can be split across tworead()calls. The buffer accumulation above handles that. - Role separation: Maintain the full
messagesarray across turns; send it each request. - CSS: Add minimal styling for
.msg.user/.msg.assistantto make it readable. - Timeout: Upstream may hang. Set a server-side
AbortControllerwith a timeout on the upstream fetch. - Cache hints: If your gateway forwards provider cache-control hints, you can pass
cache_controlin messages; the streaming protocol is unaffected.
That’s the complete streaming chat ui sse fetch tutorial. You have a working proxy, a fetch-based client that parses SSE frames, incremental rendering, and abort support—enough to drop into a larger app.