Back to blog
Tutorial·8 min read·1588 words

How to Build a Streaming AI Chatbot with Server-Sent Events: Complete Tutorial

Build a production-ready streaming chatbot using Server-Sent Events (SSE) and the OpenAI-compatible Qubax API. Full code in Node.js and Python with error handling, reconnection, and rate limiting.

How to Build a Streaming AI Chatbot with Server-Sent Events: Complete Tutorial — illustration

Streaming is the difference between a chatbot that feels alive and one that feels broken. When a user sends a message, they want to see words appear immediately — not stare at a spinner for 15 seconds while the entire response generates in the background.

This tutorial walks you through building a streaming AI chatbot using Server-Sent Events (SSE) and any OpenAI-compatible API endpoint, including Qubax. We will cover the server, the client, error handling, and production hardening.

Why Server-Sent Events?

SSE is the industry standard for streaming text from an AI API to a browser. Here is why it beats the alternatives:

  • Simplest protocol: Built on HTTP, no special server or protocol needed
  • One-way is enough: AI responses flow server→client; you do not need bidirectional WebSockets for text generation
  • Auto-reconnect: The browser's EventSource API automatically reconnects if the connection drops
  • Works everywhere: Supported by all modern browsers, curl, and every HTTP library

Prerequisites

  • Node.js 20+ (or Python 3.11+)
  • A Qubax API key (sign up at qubax.ai)
  • Basic familiarity with Express or FastAPI

Part 1: The Server (Node.js + Express)

Step 1: Install Dependencies

bash
npm init -y
npm install express cors dotenv

Step 2: Create the Streaming Endpoint

Create server.js:

javascript
import express from "express";
import cors from "cors";
import "dotenv/config";

const app = express();
app.use(cors());
app.use(express.json());

const QUBAX_API_URL = "https://api.qubax.ai/v1/chat/completions";
const API_KEY = process.env.QUBAX_API_KEY;

app.post("/api/chat", async (req, res) => {
  const { messages, model = "gpt-5.6-luna" } = req.body;

  // --- Set SSE headers ---
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "X-Accel-Buffering": "no", // Critical for nginx proxies
  });

  try {
    const upstream = await fetch(QUBAX_API_URL, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${API_KEY}`,
      },
      body: JSON.stringify({
        model,
        messages,
        stream: true,
        max_tokens: 2000,
      }),
    });

    if (!upstream.ok) {
      const errBody = await upstream.text();
      res.write(`event: error\n`);
      res.write(`data: ${JSON.stringify({ error: errBody })}\n\n`);
      res.end();
      return;
    }

    // --- Parse and forward SSE chunks ---
    const reader = upstream.body.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop(); // Keep incomplete line in buffer

      for (const line of lines) {
        if (!line.startsWith("data: ")) continue;
        const data = line.slice(6);
        if (data === "[DONE]") {
          res.write("event: done\n");
          res.write("data: {}\n\n");
          continue;
        }
        // Forward the chunk to the client
        res.write(`data: ${data}\n\n`);
      }
    }

    res.end();
  } catch (err) {
    console.error("Stream error:", err);
    res.write(`event: error\n`);
    res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
    res.end();
  }
});

// --- Heartbeat to keep connection alive ---
setInterval(() => {
  // Prevents proxy/load-balancer timeouts on idle connections
}, 15000);

app.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

The key patterns here:

  1. Set SSE headers before any data — Content-Type: text/event-stream tells the browser to treat the response as a stream
  2. Forward chunks line by line — the upstream API sends data: {...} lines; we parse and relay them
  3. Handle the `[DONE]` sentinel — OpenAI-compatible APIs use this to signal completion
  4. Always call `res.end()` — even on errors, to avoid hanging connections

Step 3: Add Rate Limiting

For production, add basic rate limiting to prevent abuse:

javascript
const rateLimit = new Map();

function rateLimiter(req, res, next) {
  const ip = req.ip;
  const now = Date.now();
  const windowMs = 60_000; // 1 minute
  const maxRequests = 20;

  if (!rateLimit.has(ip)) {
    rateLimit.set(ip, []);
  }
  const timestamps = rateLimit.get(ip).filter((t) => now - t < windowMs);
  if (timestamps.length >= maxRequests) {
    return res.status(429).json({ error: "Rate limit exceeded" });
  }
  timestamps.push(now);
  rateLimit.set(ip, timestamps);
  next();
}

app.post("/api/chat", rateLimiter, async (req, res) => {
  // ... streaming logic
});

Part 2: The Client (Vanilla JavaScript)

The HTML

html
<div id="chat">
  <div id="messages"></div>
  <form id="form">
    <input id="input" placeholder="Ask anything..." autocomplete="off" />
    <button type="submit">Send</button>
  </form>
</div>

The JavaScript

javascript
const form = document.getElementById("form");
const input = document.getElementById("input");
const messages = document.getElementById("messages");

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const userText = input.value.trim();
  if (!userText) return;

  // Show user message
  messages.innerHTML += `<div class="msg user">${userText}</div>`;
  input.value = "";

  // Create assistant message bubble
  const bubble = document.createElement("div");
  bubble.className = "msg assistant";
  messages.appendChild(bubble);
  messages.scrollTop = messages.scrollHeight;

  // --- Stream via fetch + ReadableStream ---
  const response = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      messages: [{ role: "user", content: userText }],
      model: "gpt-5.6-luna",
    }),
  });

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop();

    for (const line of lines) {
      if (line.startsWith("data: ")) {
        const data = line.slice(6);
        if (data === "[DONE]") continue;
        try {
          const json = JSON.parse(data);
          const delta = json.choices?.[0]?.delta?.content;
          if (delta) {
            bubble.textContent += delta;
            messages.scrollTop = messages.scrollHeight;
          }
        } catch (e) {
          // Ignore malformed lines
        }
      }
    }
  }
});

We use fetch with a ReadableStream rather than the EventSource API because EventSource only supports GET requests — and we need POST to send the message body.

Part 3: Python Alternative (FastAPI)

If you prefer Python, here is the equivalent server using FastAPI:

python
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx
import os
import json

app = FastAPI()

QUBAX_API_URL = "https://api.qubax.ai/v1/chat/completions"
API_KEY = os.environ["QUBAX_API_KEY"]

@app.post("/api/chat")
async def chat(request: Request):
    body = await request.json()
    messages = body["messages"]
    model = body.get("model", "gpt-5.6-luna")

    async def stream():
        async with httpx.AsyncClient() as client:
            async with client.stream(
                "POST",
                QUBAX_API_URL,
                json={
                    "model": model,
                    "messages": messages,
                    "stream": True,
                    "max_tokens": 2000,
                },
                headers={
                    "Authorization": f"Bearer {API_KEY}",
                    "Content-Type": "application/json",
                },
                timeout=120.0,
            ) as response:
                async for line in response.aiter_lines():
                    if line.startswith("data: "):
                        data = line[6:]
                        if data == "[DONE]":
                            yield "event: done\ndata: {}\n\n"
                            break
                        yield f"data: {data}\n\n"

    return StreamingResponse(
        stream(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no",
        },
    )

Run it with:

bash
pip install fastapi uvicorn httpx
uvicorn server:app --reload --port 3000

Production Checklist

Before shipping your streaming chatbot, make sure you handle these common issues:

Nginx Proxy Buffering

If you run behind nginx, it will buffer SSE responses by default, breaking streaming. Add this to your nginx config:

nginx
location /api/chat {
    proxy_pass http://localhost:3000;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;
}

Connection Timeouts

Long conversations can exceed proxy or load-balancer timeout limits. Either:

  • Set generous timeouts (120s+)
  • Send periodic heartbeat comments (: heartbeat\n\n) to keep the connection alive

Error Recovery on the Client

Handle mid-stream failures gracefully:

javascript
if (!response.ok) {
  bubble.textContent = "Sorry, something went wrong. Please try again.";
  return;
}

Token Management

For multi-turn conversations, manage context window limits:

  • Keep a rolling history of the last N messages
  • Summarize older messages to compress context
  • Set max_tokens to prevent runaway responses

Model Selection

Different models have different cost/quality trade-offs:

ModelBest ForCost (per 1M tokens)
GPT-5.6 LunaGeneral chat, fast responsesSee Qubax pricing
DeepSeek V4 FlashCost-sensitive high-volumeBudget-friendly
Claude Opus 5Complex reasoning, long contextPremium tier
GLM 5.2Ultra-cheap multilingualLowest cost

Browse all available models and their real-time pricing at qubax.ai/models.

Testing Your Stream

Verify your endpoint works with curl before building the UI:

bash
curl -N -X POST http://localhost:3000/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"Write a haiku about streaming"}]}'

The -N flag disables curl's output buffering, so you will see tokens appear one at a time.

Wrapping Up

Streaming transforms the user experience of any AI-powered application. With SSE and the OpenAI-compatible API format, you can build a production-grade streaming chatbot in under 100 lines of code.

The patterns in this tutorial — SSE headers, chunk forwarding, error handling, and rate limiting — scale from a weekend project to a production system serving thousands of concurrent users.


Ready to build? Get your API key at [Qubax](https://qubax.ai/models) and access 200+ AI models with a single OpenAI-compatible endpoint. Check the [docs](https://qubax.ai/docs) for full API reference.

FAQ

What is the difference between SSE and WebSockets for AI streaming?

SSE is simpler, uses standard HTTP, and has built-in auto-reconnect. WebSockets are bidirectional and lower-overhead for high-frequency two-way communication. For AI text streaming (server→client only), SSE is the standard choice.

Why use fetch instead of EventSource for the client?

The browser's EventSource API only supports GET requests, but AI chat APIs require POST to send the message body. Using fetch with a ReadableStream gives you POST support while still parsing SSE format.

How do I handle connection drops during streaming?

On the client, catch fetch errors and offer a "retry" button. On the server, ensure all code paths call res.end() — even error paths — to prevent hanging connections. Consider implementing resumable streams with the last-received token ID.

Can I stream multiple models at the same time?

Yes. You can open parallel SSE connections to different models and merge the streams client-side. This is useful for A/B testing model quality or building "model routing" features.

How much does it cost to run a streaming chatbot?

Cost depends on the model and token volume. Budget models like GLM 5.2 cost fractions of a cent per conversation. Frontier models like GPT-5.6 Sol cost more but deliver higher quality. See Qubax pricing for details.

Try GPT-5.6 on Qubax

OpenAI's latest. Up to 99% off retail.

View pricing

Article tags

#Streaming#Server-Sent Events#AI API#Tutorial#Chatbot
Share:Post on XTelegramLinkedInYHacker NewsReddit
Qubax AI

Qubax AI

AI Models at up to 99% off · Pay with crypto

Reading about GPT-5.6 and GPT-5? Access them — plus 340+ other models — through one API. OpenAI's latest. Up to 99% off retail.

Related articles