# Server-Sent Events: streaming from server to browser

> Server-Sent Events push updates from server to browser over a plain HTTP stream. Learn EventSource, Node streams, and why AI chat apps use SSE.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-05 | Topics: [Networking](https://flaviocopes.com/tags/network/) | Canonical: https://flaviocopes.com/server-sent-events/

You want the server to push updates to the browser. Everyone says [WebSockets](https://flaviocopes.com/websockets/).

Polling works, but it's wasteful. You hit the server every few seconds even when nothing changed.

But if data only flows one way — server to client — you don't need WebSockets. **Server-Sent Events** (SSE) is simpler.

## How it works

SSE is a normal HTTP response that stays open. The server keeps writing events to it.

The response uses `Content-Type: text/event-stream`. Each event is plain text:

```
data: Hello\n\n
```

Two newlines mark the end of an event. That's the whole protocol.

Because it's plain HTTP, SSE works through proxies and load balancers. The browser reconnects automatically if the connection drops. You get that for free.

I wrote more about the headers involved in [HTTP response headers](https://flaviocopes.com/http-response-headers/).

## The client

The browser has a built-in API for this: `EventSource`. Five lines:

```js
const source = new EventSource('/events')

source.onmessage = (event) => {
  console.log(event.data)
}
```

That's it. Open a connection, read `event.data` on every message. No library needed.

You can also listen for named events:

```js
source.addEventListener('price-update', (event) => {
  console.log(JSON.parse(event.data))
})
```

The server sends named events with an `event:` line before the `data:` line:

```
event: price-update
data: {"symbol":"AAPL","price":214.50}

```

If the connection drops, the browser reconnects on its own. The server can suggest a retry delay with a `retry:` field in the stream.

## The server

Here's a minimal Node server that pushes a timestamp every second:

```js
const http = require('http')

const server = http.createServer((req, res) => {
  if (req.url !== '/events') {
    res.writeHead(404)
    res.end()
    return
  }

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  })

  const interval = setInterval(() => {
    res.write(`data: ${new Date().toISOString()}\n\n`)
  }, 1000)

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

server.listen(3000)
```

Open `http://localhost:3000/events` in a page with the `EventSource` code above. You'll see timestamps appear every second.

## On Cloudflare Workers

The same idea works in a serverless handler. Return a `ReadableStream` from your [fetch](https://flaviocopes.com/fetch-api/) handler:

```js
export default {
  async fetch(request) {
    const encoder = new TextEncoder()

    let interval

    const stream = new ReadableStream({
      start(controller) {
        interval = setInterval(() => {
          const data = `data: ${new Date().toISOString()}\n\n`
          controller.enqueue(encoder.encode(data))
        }, 1000)
      },
      cancel() {
        clearInterval(interval)
      },
    })

    return new Response(stream, {
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
      },
    })
  },
}
```

This runs on [Cloudflare Workers](https://flaviocopes.com/cloudflare-workers/) with no special setup. It's just a streaming HTTP response.

## Why this matters right now

This is how AI chat apps stream LLM responses token by token.

When you call the OpenAI API with `stream: true`, the response is SSE. Each chunk arrives as a `data:` line with a piece of the answer. The client reads them as they come in and renders the text live.

Same pattern for Anthropic, Gemini, and most other LLM APIs. Streaming is SSE under the hood.

You don't need WebSockets for that. The data flows one way: server to browser.

## SSE vs WebSockets

| | SSE | WebSockets |
|---|---|---|
| Direction | Server → client only | Bidirectional |
| Protocol | Plain HTTP | Separate `ws://` protocol |
| Reconnection | Built into the browser | You build it yourself |
| Complexity | Low | Higher |

Use SSE when the server pushes and the client just listens. Stock prices, live scores, AI chat streams, progress updates.

Use WebSockets when both sides need to send data fast. Games, collaborative editing, real-time multiplayer.

## Limits worth knowing

SSE is one-way. The client can't send data back over the same connection. Use a regular HTTP request or WebSockets for that.

On HTTP/1.1, browsers cap open connections at about 6 per domain. If you open many SSE streams on the same origin, you can hit that limit. On HTTP/2, the cap goes away — all streams share one connection.

For most apps, one or two SSE connections is plenty. The HTTP/1.1 limit rarely bites.

SSE won't replace WebSockets everywhere. But for one-way streaming, it's the tool I'd reach for first.
