Server-Sent Events

EventSource streams text from server to client over plain HTTP, reconnects on its own, and resumes with Last-Event-ID.

Syntaxnew EventSource(url: string, options?: { withCredentials?: boolean }): EventSource

Server-Sent Events is a one-way stream. The server holds an HTTP response open and writes text events into it; the browser hands each one to you as a DOM event. The client side is a single built-in object, EventSource. There is no handshake protocol and no second port, because the request is an ordinary GET whose response has the content type text/event-stream.

How it works

new EventSource(url, { withCredentials: true }) opens the stream immediately. It fires open, then one event per block the server writes. A block with no name arrives as message; a block whose first line is event: price arrives as a price event, which you receive with addEventListener('price', handler). The payload is always a string in event.data, so JSON needs parsing. source.onmessage = handler is the shorthand for source.addEventListener('message', handler); the property holds one handler, and assigning again replaces it.

Reconnection is included

If the stream drops, the browser reconnects by itself and sends a Last-Event-ID request header carrying the last id: it saw, so the server can resume where it stopped. A retry: line in the stream sets the wait, in milliseconds.

Where it stops

Nothing travels upward: to send something, make a normal request. You cannot add request headers either, so there is no Authorization header; use a cookie with withCredentials, or a token in the URL. Inside error, read source.readyState: 0 (CONNECTING) means a retry is coming, 2 (CLOSED) means it gave up, which is what a non-200 response or the wrong content type produces.

Example

Example · vue
// src/composables/usePriceStream.ts
import { onScopeDispose, readonly, ref } from 'vue'

export interface PriceTick {
  symbol: string
  price: number
}

export function usePriceStream(url: string) {
  const connected = ref(false)
  const givenUp = ref(false)
  const latest = ref<PriceTick | null>(null)
  const source = new EventSource(url, { withCredentials: true })

  source.onopen = () => {
    connected.value = true
    givenUp.value = false
  }

  source.onerror = () => {
    connected.value = false
    givenUp.value = source.readyState === EventSource.CLOSED
  }

  source.addEventListener('price', (event) => {
    latest.value = JSON.parse((event as MessageEvent<string>).data) as PriceTick
  })

  onScopeDispose(() => source.close(), true)

  return {
    connected: readonly(connected),
    givenUp: readonly(givenUp),
    latest: readonly(latest)
  }
}

When to use it

  • Follow a long import or export job's progress on a read-only screen.
  • Push a notification count to a header badge without polling.
  • Stream a server computation's partial results into a results pane.
  • Replace a five-second polling loop on a dashboard with a single open stream.

More examples

What the server actually writes

The wire format explains every client-side detail. A blank line ends an event, which is why a stream that never writes one looks silent. The id value becomes the Last-Event-ID header on the next reconnect, so the server can replay from 1025 onward. The retry line sets the browser's reconnect wait to three seconds. A line starting with a colon is a comment and is ignored, and sending one every 20 to 30 seconds stops an idle proxy from closing the response. Read it with curl before blaming the browser: curl -N shows these blocks exactly as written.

Example · text
# response to GET /api/prices
# Content-Type: text/event-stream
# Cache-Control: no-store

retry: 3000

id: 1024
event: price
data: {"symbol":"ACME","price":31.4}

id: 1025
event: price
data: {"symbol":"ACME","price":31.6}

: this comment line keeps the connection warm through a proxy

Rendering the stream

The widget shows the last quote, or a placeholder before the first event arrives, and a line underneath that distinguishes a reconnect in progress from a stream the browser has stopped retrying. The two conditions are deliberately split with v-if and v-else-if: givenUp needs a different message because no further events are coming without a page action. Keeping the last price visible rather than clearing it is the right default, provided the connection line says it may be out of date.

Example · vue
<!-- src/components/PriceWidget.vue -->
<script setup lang="ts">
import { usePriceStream } from '@/composables/usePriceStream'

const { connected, givenUp, latest } = usePriceStream('/api/prices')
</script>

<template>
  <section class="price">
    <p v-if="latest">{{ latest.symbol }} {{ latest.price.toFixed(2) }}</p>
    <p v-else>No quote yet</p>
    <p v-if="givenUp">Stream closed by the server</p>
    <p v-else-if="!connected">Reconnecting</p>
  </section>
</template>

<style scoped>
.price {
  font-variant-numeric: tabular-nums;
}
</style>

Choosing between the two transports

Reach for SSE when the client only reads: dashboards, prices, job progress, notification counts. You get reconnection, resume by event id and curl-level debugging without writing any of it. Reach for a WebSocket when the client also sends on the same connection, or when the payload is binary. The HTTP/1.1 connection limit is the one that surprises people: an open stream occupies one of roughly six connections per origin, so three tabs of the same app can starve its own API requests. Over HTTP/2 the limit is the negotiated stream count, typically 100.

Example · text
                      | EventSource (SSE)        | WebSocket
----------------------|--------------------------|-------------------------
direction             | server to client only    | both directions
protocol              | plain HTTP GET           | ws:// upgrade handshake
reconnect             | built in, with resume    | you write it
request headers       | not settable             | not settable
payload               | text (event.data string) | text or binary
readyState values     | 0, 1, 2                  | 0, 1, 2, 3
                      | EventSource (SSE)        | WebSocket
----------------------|--------------------------|-------------------------
direction             | server to client only    | both directions
protocol              | plain HTTP GET           | ws:// upgrade handshake
reconnect             | built in, with resume    | you write it
request headers       | not settable             | not settable
payload               | text (event.data string) | text or binary
readyState values     | 0, 1, 2                  | 0, 1, 2, 3
per-origin streams    | about 6 on HTTP/1.1      | own pool, ~200 per host

: the HTTP/1.1 six-connection limit does not apply to WebSockets

Discussion

  • Be the first to comment on this lesson.