n4nAI

Building a streaming chat UI with SSE and fetch()

Step-by-step streaming chat ui sse fetch tutorial: build a minimal browser chat that streams LLM responses via fetch() and SSE parsing.

n4n Team2 min read482 words

Audio narration

Coming soon — every post will get a voice note here.

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 fetch available)
  • 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.
  • curl for 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 two read() calls. The buffer accumulation above handles that.
  • Role separation: Maintain the full messages array across turns; send it each request.
  • CSS: Add minimal styling for .msg.user / .msg.assistant to make it readable.
  • Timeout: Upstream may hang. Set a server-side AbortController with a timeout on the upstream fetch.
  • Cache hints: If your gateway forwards provider cache-control hints, you can pass cache_control in 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.

Tagsssechat-uifetch-apistreamingtutorial

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All websockets vs sse for llm streaming posts →