TL;DR: Server-Sent Events (SSE) is how a server streams a continuous flow of data to a browser over a regular HTTP connection. The browser opens one connection and the server keeps it alive, pushing updates as they happen. It's the technology behind AI streaming responses, live notifications, and real-time dashboards — and it works without the complexity of WebSockets.

Why AI Coders Need to Know This

When you use the Anthropic or OpenAI API in stream mode, those tokens don't all arrive at once. The AI generates them one at a time and the API sends each one immediately. To display that word-by-word effect in your own app, you need to stream that data to the browser — and SSE is the cleanest way to do it.

AI tools like Cursor and Claude Code will generate SSE endpoints for you. But the code they produce often has silent bugs: buffering that kills the stream, missing reconnection logic, or headers that look right but don't work behind a proxy. If you've ever deployed a streaming feature and watched it mysteriously stop working in production while it worked fine locally — a proxy or nginx config was probably eating your stream.

SSE is also the right tool for live notifications, progress updates on long-running jobs, and any other situation where your server has new data to push to the browser regularly. Understanding the mechanics helps you tell AI exactly what you need — and spot it when something is broken.

The Mental Model: A Radio Broadcast

Think about regular HTTP requests like ordering food at a restaurant. You place an order (the request), the kitchen prepares everything, and the waiter brings it all out at once when it's ready (the response). You can't eat until the kitchen is completely done.

SSE is more like a live radio broadcast. The station (server) starts broadcasting and keeps the signal going continuously. Your radio (browser) tunes in once, and from that point on, whatever the station broadcasts comes to you the instant it's transmitted. The station doesn't wait for you to ask — it just keeps sending.

There's a key difference from two-way radio (WebSockets): with SSE, only the station broadcasts. You can listen, but you can't talk back over the same connection. For things like AI chat — where you send a message and stream the response — that's actually fine. Your message goes via a normal REST API POST, and the AI's response streams back over SSE. Two different jobs, two different tools.

SSE vs. WebSocket: When to Use Which

Before we dive into code, let's answer the question that always comes up. We have a full breakdown at WebSocket vs. SSE, but here's the quick version:

Use SSE when: The server pushes data and the client just listens. AI streaming responses, live notifications, stock tickers, progress bars, news feeds.

Use WebSockets when: Both sides need to send messages in real time. Chat apps, multiplayer games, collaborative document editing.

SSE wins on simplicity: it runs over regular HTTP (so it works with existing auth, load balancers, and firewalls without special configuration), the browser handles reconnection automatically, and you don't need a special library. WebSockets require a protocol upgrade and more setup, but give you that two-way channel when you need it.

Real Scenario

Prompt I Would Type

Build me an Express endpoint that streams an AI response
token-by-token using Server-Sent Events. The client should
receive each token as it arrives, with proper reconnection
handling and a [DONE] signal when the stream ends.

This is the prompt that gets you a production-ready SSE streaming setup. Here's what a well-tuned AI will generate — and then we'll break down every piece so you understand what's actually happening.

What AI Generated

Step 1: The SSE Server Endpoint (Node.js / Express)

// routes/stream.js
const express = require('express');
const Anthropic = require('@anthropic-ai/sdk');
const router = express.Router();
const client = new Anthropic();

router.get('/api/stream', async (req, res) => {
  const userMessage = req.query.message || 'Tell me something interesting.';

  // These four headers turn this into an SSE connection
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no'); // Tells nginx: don't buffer this

  // Send a comment to establish the connection immediately
  res.write(': connected\n\n');

  try {
    // Stream from the Claude API
    const stream = await client.messages.stream({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      messages: [{ role: 'user', content: userMessage }]
    });

    // Each text delta is one SSE event
    stream.on('text', (text) => {
      res.write(`data: ${JSON.stringify({ token: text })}\n\n`);
    });

    // When the stream ends, send the DONE signal
    stream.on('end', () => {
      res.write('data: [DONE]\n\n');
      res.end();
    });

    // Handle errors mid-stream
    stream.on('error', (err) => {
      res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
      res.end();
    });

  } catch (err) {
    res.write(`data: ${JSON.stringify({ error: 'Stream failed to start' })}\n\n`);
    res.end();
  }
});

// Clean up when client disconnects
req.on('close', () => {
  console.log('Client disconnected — stream cleaned up');
});

module.exports = router;

Step 2: The Client-Side EventSource

// public/app.js

function streamAIResponse(message) {
  const outputEl = document.getElementById('output');
  outputEl.textContent = ''; // Clear previous response

  // EventSource opens the SSE connection — that's all it takes
  const evtSource = new EventSource(
    `/api/stream?message=${encodeURIComponent(message)}`
  );

  // This fires every time the server sends a 'data:' line
  evtSource.onmessage = (event) => {
    if (event.data === '[DONE]') {
      // Stream is complete — close the connection
      evtSource.close();
      return;
    }

    try {
      const parsed = JSON.parse(event.data);

      if (parsed.error) {
        outputEl.textContent += `\n[Error: ${parsed.error}]`;
        evtSource.close();
        return;
      }

      // Append each token as it arrives
      outputEl.textContent += parsed.token;
    } catch (e) {
      console.warn('Could not parse SSE data:', event.data);
    }
  };

  evtSource.onerror = (err) => {
    console.error('SSE connection error:', err);
    // EventSource will automatically attempt to reconnect
    // You can call evtSource.close() here if you want to stop retrying
  };
}

// Trigger on button click
document.getElementById('submit').addEventListener('click', () => {
  const message = document.getElementById('input').value;
  streamAIResponse(message);
});

Step 3: SSE with Named Events and Retry

// Server — sending different event types and a retry interval
function sendEvent(res, eventType, data, id) {
  if (id !== undefined) {
    res.write(`id: ${id}\n`);          // Event ID for reconnection tracking
  }
  res.write(`event: ${eventType}\n`);  // Named event type
  res.write(`retry: 3000\n`);          // Tell browser to wait 3s before retry
  res.write(`data: ${JSON.stringify(data)}\n\n`); // The payload
}

// Usage
sendEvent(res, 'token', { text: 'Hello' }, 1);
sendEvent(res, 'token', { text: ' world' }, 2);
sendEvent(res, 'done', { tokens: 152, model: 'claude-sonnet-4-6' }, 3);
// Client — listening to named events
const evtSource = new EventSource('/api/stream');

evtSource.addEventListener('token', (event) => {
  const { text } = JSON.parse(event.data);
  outputEl.textContent += text;
});

evtSource.addEventListener('done', (event) => {
  const { tokens, model } = JSON.parse(event.data);
  console.log(`Stream complete: ${tokens} tokens from ${model}`);
  evtSource.close();
});

Understanding Each Part

The SSE Wire Format

SSE has its own simple text format. Every message the server sends looks like this over the wire:

data: {"token": "Hello"}\n\n

The rules are simple:

  • Each field starts with its name (data:, event:, id:, retry:) followed by a space and the value.
  • A single \n separates fields within one event.
  • A blank line (\n\n) — two newlines — signals the end of one event. This is the trigger that fires onmessage in the browser.
  • Lines starting with : are comments — the server sends them to keep the connection alive (like a heartbeat).

That's the entire protocol. No binary encoding, no handshake ceremony — just text over HTTP. You could read an SSE stream with curl in your terminal right now.

The Four Required Headers

These headers turn a regular HTTP response into an SSE stream:

  • Content-Type: text/event-stream — tells the browser this is SSE, not HTML or JSON.
  • Cache-Control: no-cache — prevents any caching layer from holding the response until it's complete.
  • Connection: keep-alive — tells the server to keep the TCP connection open instead of closing after the first chunk.
  • X-Accel-Buffering: no — tells nginx specifically not to buffer this response. Without this, nginx collects everything and sends it in one shot, which kills the streaming effect entirely.

The EventSource API

The browser's built-in EventSource is what makes the client side easy. You create it with a URL, and it handles everything else: opening the HTTP connection, parsing the SSE format, firing events, and reconnecting if the connection drops.

The key behaviors to know:

  • Automatic reconnection: If the connection drops, EventSource waits (default 3 seconds) and tries again. You don't write any retry logic — it's built in.
  • Last-Event-ID: If the server sent id: fields, EventSource remembers the last one it received. On reconnect, it sends that ID in a Last-Event-ID header so the server can resume from where it left off.
  • Named events: Generic data: messages fire onmessage. Named events (sent with event: token) require addEventListener('token', ...) to receive — they won't fire onmessage.

EventSource always uses GET requests and doesn't support custom headers out of the box. This matters for auth — more on that in the gotchas section below.

Retry and Reconnection

Think of the retry mechanism like a contractor who steps outside for a minute and comes back to check if the door is unlocked again. The browser's EventSource will keep knocking. The server's job is to remember where the conversation left off.

When you send id: 42\n with an event, you're giving that event a sequence number. If the connection drops and the client reconnects, it sends Last-Event-ID: 42 in the request headers. Your server can read that and replay any events after ID 42 — so the client never misses data.

// Reading the last event ID on reconnect
router.get('/api/stream', (req, res) => {
  const lastEventId = req.headers['last-event-id'];

  if (lastEventId) {
    // Client reconnected — replay events after this ID
    const missedEvents = getEventsSince(lastEventId);
    missedEvents.forEach((event, i) => {
      res.write(`id: ${lastEventId + i + 1}\n`);
      res.write(`data: ${JSON.stringify(event)}\n\n`);
    });
  }

  // Then continue with live stream...
});

How This Connects to Async/Await

Streaming responses are inherently asynchronous — the server is doing work over time and sending results as they become available. The pattern of using stream.on('text', callback) is event-driven async: the server registers a callback that fires each time a new chunk arrives, rather than waiting for everything to be ready at once. Understanding that async model helps you reason about why the stream can be interrupted, why cleanup matters, and why you need to handle both the end and error events.

What AI Gets Wrong About SSE

1. Missing the X-Accel-Buffering Header

This is the most common production failure. The code works perfectly locally but breaks the moment you deploy behind nginx. Without X-Accel-Buffering: no, nginx buffers your SSE response — it collects chunks and waits to see if there's more before sending anything to the browser.

// ❌ BROKEN in production behind nginx — buffering kills the stream
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Missing: X-Accel-Buffering header
// ✅ WORKS — disables nginx buffering for this endpoint
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');

You also need to make sure your nginx config has proxy_buffering off; for the SSE location block. The header alone may not be enough depending on your nginx version.

2. Not Flushing the Response

Node.js and Express buffer writes internally before sending to the network. For SSE, you need each res.write() to go out immediately. In most Express setups, res.write() does flush automatically — but if you're behind a compression middleware like compression(), it can buffer output and break streaming.

// ❌ PROBLEM — compression middleware buffers SSE output
app.use(compression()); // Applied to ALL routes

// ✅ FIX — skip compression for SSE endpoints
app.use(compression({
  filter: (req, res) => {
    if (req.path.startsWith('/api/stream')) return false;
    return compression.filter(req, res);
  }
}));

3. Not Cleaning Up When the Client Disconnects

When a user closes their browser tab mid-stream, the SSE connection drops — but your server-side code might keep running. If you're streaming from an AI API, you're still paying for tokens that no one will ever read. Always listen for the client disconnect:

// ❌ WASTEFUL — keeps calling the AI API after client is gone
router.get('/api/stream', async (req, res) => {
  const stream = await client.messages.stream({ /* ... */ });
  stream.on('text', (text) => res.write(`data: ${text}\n\n`));
});
// ✅ EFFICIENT — cancels the AI stream when client disconnects
router.get('/api/stream', async (req, res) => {
  const stream = await client.messages.stream({ /* ... */ });

  stream.on('text', (text) => res.write(`data: ${text}\n\n`));

  req.on('close', () => {
    stream.abort(); // Stop the AI API call
    console.log('Client disconnected — stream aborted');
  });
});

4. Using EventSource Where POST Is Needed

EventSource only supports GET requests. But if your prompt is long (a multi-paragraph message, file content, conversation history), you can't fit it in a URL query string. AI often misses this and puts the prompt in the URL, which fails for anything but toy examples.

The standard solution is a two-step pattern: POST the prompt first, get back a short stream ID, then open an EventSource using that ID:

// Step 1: POST the prompt, get back a stream token
const { streamId } = await fetch('/api/stream/init', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
  body: JSON.stringify({ messages: conversationHistory })
}).then(r => r.json());

// Step 2: Open SSE connection with just the stream ID
const evtSource = new EventSource(`/api/stream/${streamId}`);

This pattern also solves the auth problem — you can pass your auth token in the POST step, then the server validates it and creates a short-lived stream token that doesn't need to be in the URL.

5. Not Handling the onerror Event

AI-generated EventSource clients often only handle onmessage and ignore onerror. The error handler fires not just on real errors but also when the connection drops before reconnecting — so if you don't handle it, you'll see unhandled errors in the console even during normal reconnection behavior.

// ❌ INCOMPLETE — no error handling
const evtSource = new EventSource('/api/stream');
evtSource.onmessage = (e) => console.log(e.data);
// ✅ COMPLETE — handles errors and knows when to give up
const evtSource = new EventSource('/api/stream');
let retryCount = 0;
const MAX_RETRIES = 5;

evtSource.onmessage = (e) => { /* handle data */ };

evtSource.onerror = (err) => {
  retryCount++;
  if (retryCount >= MAX_RETRIES) {
    console.error('SSE failed after max retries — giving up');
    evtSource.close();
    showErrorToUser('Connection lost. Please refresh.');
  }
  // Otherwise, EventSource will retry automatically
};

Common Use Cases

AI Streaming Responses

This is why most vibe coders encounter SSE. When you build a chatbot using Claude, GPT-4, or any other LLM, the API can stream tokens as they're generated. SSE is the standard way to pipe that stream to the browser. The Anthropic SDK, OpenAI SDK, and most LLM libraries have built-in streaming support that plays naturally with SSE.

The token-by-token effect isn't just cosmetic — it makes your app feel dramatically faster. Users start reading the response immediately instead of staring at a spinner for 10 seconds.

Live Notifications

Instead of polling your REST API every 5 seconds to check for new notifications, open one SSE connection on page load. The server pushes notifications the instant they happen. This is simpler than WebSockets and more efficient than polling.

// Server — push a notification to a specific user's SSE connection
const clients = new Map(); // userId -> res object

router.get('/api/notifications/stream', (req, res) => {
  const userId = req.user.id;

  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');

  // Register this connection
  clients.set(userId, res);

  // Clean up when client disconnects
  req.on('close', () => clients.delete(userId));
});

// Call this when a notification is triggered
function pushNotification(userId, notification) {
  const clientRes = clients.get(userId);
  if (clientRes) {
    clientRes.write(`event: notification\n`);
    clientRes.write(`data: ${JSON.stringify(notification)}\n\n`);
  }
}

Progress Bars for Long-Running Jobs

When a user triggers a job that takes 30 seconds — a report export, a batch import, a video transcode — don't make them stare at a spinner. Stream progress updates via SSE:

// Server — stream job progress
router.get('/api/jobs/:jobId/progress', (req, res) => {
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('X-Accel-Buffering', 'no');

  const interval = setInterval(() => {
    const progress = getJobProgress(req.params.jobId);

    res.write(`data: ${JSON.stringify({ percent: progress.percent, step: progress.currentStep })}\n\n`);

    if (progress.percent >= 100) {
      res.write(`event: complete\ndata: ${JSON.stringify({ result: progress.result })}\n\n`);
      clearInterval(interval);
      res.end();
    }
  }, 500); // Check every 500ms

  req.on('close', () => clearInterval(interval));
});

Stock Tickers and Live Data Feeds

Any data that changes frequently and needs to reach the browser in near real-time is a good fit for SSE: stock prices, sports scores, sensor readings, server metrics. The server pushes updates whenever data changes, rather than waiting for the client to ask.

The nginx Configuration You Actually Need

SSE working locally but broken in production almost always comes back to nginx. Here's the config that makes SSE work properly:

# nginx.conf — location block for your SSE endpoint
location /api/stream {
    proxy_pass http://your-node-app:3000;
    proxy_http_version 1.1;

    # Disable buffering — this is the critical setting
    proxy_buffering off;
    proxy_cache off;

    # Keep the connection open
    proxy_set_header Connection '';
    proxy_read_timeout 3600s;  # 1 hour — adjust to your max stream duration

    # Pass through the SSE headers
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # Chunked transfer encoding — required for streaming
    chunked_transfer_encoding on;
}

The two settings that matter most: proxy_buffering off stops nginx from collecting chunks before sending them, and proxy_http_version 1.1 enables HTTP/1.1 features like chunked transfer encoding that SSE depends on.

If you're on HTTP/2, SSE works even better — HTTP/2 multiplexes streams over a single connection, so multiple SSE subscriptions don't each need their own TCP connection.

Quick Check: What HTTP Status to Expect

A healthy SSE connection returns HTTP 200 immediately when the connection is established — before any data arrives. If you see 200 but no data, the connection opened but nothing is streaming yet (or buffering is blocking the data). If you see a 4xx or 5xx, there's a server-side problem before the stream even starts.

In your browser DevTools, go to the Network tab, filter by "EventStream," and click on your SSE request. You'll see the EventStream tab showing each event as it arrives in real time — the best debugging tool you have for SSE.

How to Debug SSE with AI Tools

In Cursor

When SSE works locally but not in production, highlight the streaming endpoint and ask: "My SSE endpoint works in development but shows no streaming in production. Check the headers, look for compression middleware that might be buffering output, and verify I'm sending X-Accel-Buffering: no for nginx." Cursor can scan your middleware stack and spot what's blocking the stream.

For diagnosing missing cleanup: "Review this SSE route and tell me if there's a memory leak — does it properly clean up when clients disconnect?" Cursor can trace the event listener lifecycle and spot dangling references.

In Windsurf

Use Cascade for cross-file analysis when your SSE setup spans multiple files: "Trace the full SSE lifecycle in this codebase — from when a client connects through to cleanup on disconnect. Find any place where a connection might not be properly closed or cleaned up." Windsurf's multi-file analysis is particularly good at finding the subtle resource leak patterns that happen in SSE.

In Claude Code

Paste your EventSource client code alongside the server endpoint and ask: "The stream connects but I never receive data events. Walk through the SSE message format — are the \n\n delimiters correct? Is the Content-Type header right? Is anything in the middleware stack compressing or buffering the response?" Claude Code excels at tracing the exact wire format and spotting off-by-one errors in the \n\n delimiters that silently break SSE parsing.

Quick Diagnostic: Test with curl

Before assuming a browser issue, test your SSE endpoint directly with curl. If curl streams output correctly, the problem is client-side or in your EventSource setup:

# If this shows events streaming in real time, your server is fine
curl -N http://localhost:3000/api/stream?message=hello

# The -N flag disables curl's own buffering — without it,
# curl buffers output and you won't see streaming even if it works

If curl shows nothing for 30 seconds then dumps everything, your server isn't flushing. If curl streams but the browser doesn't, check your nginx config or compression middleware.

What to Learn Next

Frequently Asked Questions

Server-Sent Events (SSE) is a web technology that lets a server push a continuous stream of data to a browser over a regular HTTP connection. The browser opens one connection and the server keeps it open, sending updates whenever it has new data. Unlike WebSockets, SSE is one-way — only the server sends, the client just listens.

SSE is one-way (server to client only) and runs over plain HTTP, making it simpler to set up and firewall-friendly. WebSockets are bidirectional (both sides can send) and use a different protocol (ws://). Use SSE for live feeds, notifications, and AI streaming responses. Use WebSockets for chat, multiplayer games, or anything that needs real-time messages going both directions.

Nginx buffers responses by default, which means it waits to collect a full response before sending it to the browser — breaking SSE's streaming behavior. You need to disable buffering for your SSE endpoint by adding 'proxy_buffering off;' and 'X-Accel-Buffering: no' headers to your nginx config. Without this, users see nothing until the stream ends or times out.

Yes — the browser's EventSource API automatically reconnects if the connection drops. It waits a few seconds (configurable via the 'retry:' field in SSE messages) and then re-opens the connection. You can also send an 'id:' field with each event so the browser can tell the server the last event it received, allowing the server to catch it up on missed events.

SSE is ideal for AI chatbot streaming responses (the token-by-token effect you see in ChatGPT), live notifications, real-time dashboards, stock price tickers, progress bars for long-running jobs, and social media live feeds. Any situation where the server has new data to push to the browser on an ongoing basis is a good fit for SSE.