Server-Sent Events
EventSource streams text from server to client over plain HTTP, reconnects on its own, and resumes with Last-Event-ID.
new EventSource(url: string, options?: { withCredentials?: boolean }): EventSourceServer-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
// 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.
# 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 proxyRendering 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.
<!-- 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.
| 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