Vue EventSource Composables Permalink to this section
Part of Frontend Consumption & Client Patterns.
The Vue 3 Composition API is a natural fit for wrapping the browser’s EventSource object. A composable can expose reactive ref values for connection state, the last message, and accumulated data, handle onUnmounted cleanup automatically, and wire up exponential-backoff reconnection — all in one reusable unit that components consume with a single function call. Without this encapsulation, engineers end up with EventSource instances scattered across onMounted hooks, missing close() calls on teardown, and no shared reconnection logic.
This guide covers the complete pattern: the reactive wiring, typed named events, reconnection strategy, proxy/CDN edge cases, memory and connection-count considerations, and debugging workflows.
How It Works Permalink to this section
The browser’s EventSource object opens a persistent HTTP connection and emits DOM events for each SSE message. The WHATWG HTML spec defines three event names by default — message, error, and open — plus arbitrary named events dispatched by the server via event: field lines.
A Vue 3 composable wraps this in a function that:
- Creates an
EventSourceinside the composable scope (not inside a lifecycle hook — the composable itself is called synchronously fromsetup()) - Attaches
ref-backed event listeners so Vue’s reactivity system picks up every update - Registers an
onUnmountedcallback to calles.close(), preventing ghost connections after the component is destroyed - Optionally drives a reconnection timer because the browser’s built-in retry fires unconditionally — your composable can add exponential backoff and a max-attempts guard
Wire Format Recap Permalink to this section
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no
id: 1
event: price
data: {"symbol":"AAPL","price":189.42}
id: 2
data: plain message, no event field → dispatched as "message"
retry: 3000
The retry line (milliseconds) overrides the browser’s default (typically 3 s). Named events (event: price) must be subscribed via es.addEventListener('price', handler), not es.onmessage.
Composable Implementation Permalink to this section
Below is a production-ready useEventSource composable in TypeScript. It supports typed named events, exponential backoff, and a Last-Event-ID header sent automatically by the browser on reconnect.
// composables/useEventSource.ts
import { ref, onUnmounted, readonly } from 'vue'
export type SSEStatus = 'connecting' | 'open' | 'closed' | 'error'
export interface UseEventSourceOptions<T = unknown> {
/** Named event to listen for (default: 'message') */
eventName?: string
/** Transform raw event.data string into T */
transform?: (raw: string) => T
/** Max reconnection attempts (default: 10, 0 = no limit) */
maxRetries?: number
/** Initial backoff ms (default: 1000); doubles each attempt, caps at 30 s */
initialDelay?: number
/** Pass cookies / auth headers: EventSource with credentials */
withCredentials?: boolean
}
export interface UseEventSourceReturn<T> {
status: Readonly<ReturnType<typeof ref<SSEStatus>>>
data: Readonly<ReturnType<typeof ref<T | null>>>
error: Readonly<ReturnType<typeof ref<Event | null>>>
close: () => void
reconnect: () => void
}
export function useEventSource<T = unknown>(
url: string | (() => string),
options: UseEventSourceOptions<T> = {}
): UseEventSourceReturn<T> {
const {
eventName = 'message',
transform = (raw: string) => raw as unknown as T,
maxRetries = 10,
initialDelay = 1000,
withCredentials = false,
} = options
const status = ref<SSEStatus>('connecting')
const data = ref<T | null>(null)
const error = ref<Event | null>(null)
let es: EventSource | null = null
let attempts = 0
let retryTimer: ReturnType<typeof setTimeout> | null = null
let stopped = false // set true on explicit close() or unmount
function resolveUrl(): string {
return typeof url === 'function' ? url() : url
}
function connect(): void {
if (stopped) return
status.value = 'connecting'
es = new EventSource(resolveUrl(), { withCredentials })
es.addEventListener('open', () => {
attempts = 0 // reset backoff on successful open
status.value = 'open'
})
es.addEventListener(eventName, (evt: MessageEvent) => {
error.value = null
try {
data.value = transform(evt.data)
} catch (parseErr) {
console.error('[useEventSource] transform error', parseErr)
}
})
es.addEventListener('error', (evt: Event) => {
error.value = evt
status.value = 'error'
es?.close() // browser already closed it; be explicit
es = null
scheduleReconnect()
})
}
function scheduleReconnect(): void {
if (stopped) return
if (maxRetries > 0 && attempts >= maxRetries) {
status.value = 'closed'
return
}
// Exponential backoff with jitter: delay = min(initial * 2^n, 30000) ± 10%
const base = Math.min(initialDelay * 2 ** attempts, 30_000)
const jitter = base * 0.1 * (Math.random() * 2 - 1)
const delay = Math.round(base + jitter)
attempts++
retryTimer = setTimeout(connect, delay)
}
function close(): void {
stopped = true
clearTimeout(retryTimer ?? undefined)
es?.close()
es = null
status.value = 'closed'
}
function reconnect(): void {
stopped = false
attempts = 0
close()
stopped = false // close() sets stopped = true, so reset
connect()
}
// Kick off immediately
connect()
// Teardown when component unmounts
onUnmounted(close)
return {
status: readonly(status),
data: readonly(data),
error: readonly(error),
close,
reconnect,
}
}
Key design decisions:
| Decision | Rationale |
|---|---|
onUnmounted inside composable |
Cleanup is co-located with the resource; no boilerplate in components |
readonly() wrapping refs |
Prevents accidental mutation from consumer; keeps single source of truth |
url as string | () => string |
Supports reactive URL computation without requiring a watchEffect in every caller |
transform callback |
Keeps JSON parsing out of the composable core; lets callers type-guard at runtime |
stopped flag |
Distinguishes intentional close from network error so backoff does not loop after unmount |
| Jitter on backoff | Avoids thundering-herd when many tabs reconnect simultaneously after a server restart |
Component Consumption Permalink to this section
Basic Usage Permalink to this section
// components/PriceTicker.vue
<script setup lang="ts">
import { useEventSource } from '@/composables/useEventSource'
interface PriceEvent {
symbol: string
price: number
}
const { status, data, error, reconnect } = useEventSource<PriceEvent>(
'/api/prices/stream',
{
eventName: 'price',
transform: (raw) => JSON.parse(raw) as PriceEvent,
maxRetries: 8,
initialDelay: 1500,
withCredentials: true, // send session cookie
}
)
</script>
<template>
<div>
<span :class="`badge badge--${status}`">{{ status }}</span>
<p v-if="data">{{ data.symbol }}: ${{ data.price.toFixed(2) }}</p>
<p v-if="error" class="error">
Stream error — <button @click="reconnect">retry now</button>
</p>
</div>
</template>
Multiple Named Events on One Connection Permalink to this section
The EventSource spec allows a single connection to carry many event types. Use a variant that registers multiple listeners and exposes per-event refs:
// composables/useMultiEventSource.ts
import { ref, onUnmounted, readonly } from 'vue'
export function useMultiEventSource(
url: string,
eventNames: string[],
withCredentials = false
) {
const streams = Object.fromEntries(
eventNames.map((name) => [name, ref<string | null>(null)])
)
const status = ref<'connecting' | 'open' | 'closed' | 'error'>('connecting')
const es = new EventSource(url, { withCredentials })
es.addEventListener('open', () => { status.value = 'open' })
es.addEventListener('error', () => { status.value = 'error' })
for (const name of eventNames) {
es.addEventListener(name, (evt: MessageEvent) => {
streams[name].value = evt.data
})
}
onUnmounted(() => es.close())
return { status: readonly(status), streams }
}
Usage in a dashboard that receives cpu, mem, and disk events from the same endpoint:
<script setup lang="ts">
import { useMultiEventSource } from '@/composables/useMultiEventSource'
const { status, streams } = useMultiEventSource(
'/api/metrics/stream',
['cpu', 'mem', 'disk']
)
</script>
<template>
<MetricCard label="CPU" :value="streams.cpu.value" />
<MetricCard label="Memory" :value="streams.mem.value" />
<MetricCard label="Disk" :value="streams.disk.value" />
</template>
Integrating with Pinia Permalink to this section
For state-management integration where SSE data must live in a global store, call the composable from a Pinia store’s setup() syntax:
// stores/notifications.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useEventSource } from '@/composables/useEventSource'
export const useNotificationStore = defineStore('notifications', () => {
const items = ref<string[]>([])
// composables are valid inside defineStore setup functions
const { data, status } = useEventSource<string>(
'/api/notifications/stream',
{ transform: (raw) => raw }
)
// Watch data changes and accumulate into the store
watch(data, (msg) => {
if (msg) items.value.unshift(msg)
if (items.value.length > 200) items.value.pop() // cap at 200
})
return { items, streamStatus: status }
})
Note: calling onUnmounted inside a store setup does fire when the store is disposed (Pinia $dispose()). If the store is never disposed, the connection persists for the app’s lifetime — which is often what you want for a global notification feed.
Edge Cases & Network Interference Permalink to this section
SSE connections traverse multiple network layers before the browser sees data. Each layer is a potential failure point.
Proxy Buffering Permalink to this section
Nginx and Apache buffer responses by default. A composable that opens correctly but delivers no events for 30+ seconds almost certainly has a buffering proxy in front:
# nginx — per-location SSE config
location /api/stream {
proxy_pass http://upstream;
proxy_buffering off; # disable response buffer
proxy_cache off;
proxy_set_header X-Accel-Buffering no;
proxy_read_timeout 3600s; # hold connection for up to 1 h
chunked_transfer_encoding on;
}
Alternatively, set the X-Accel-Buffering: no response header from the server itself (nginx respects it):
# FastAPI example
from fastapi.responses import StreamingResponse
def event_generator():
yield "data: hello\n\n"
@app.get("/stream")
def stream():
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"X-Accel-Buffering": "no", "Cache-Control": "no-cache"},
)
CDN Stripping Permalink to this section
Cloudflare and similar CDNs buffer SSE by default on certain plans. You need either:
- Cloudflare Enterprise with Cloudflare Streaming enabled, or
- Cache rules that bypass caching for
Content-Type: text/event-stream
HTTP/2 and Connection Multiplexing Permalink to this section
Under HTTP/2, multiple EventSource connections from the same origin share one TCP socket (multiplexed). The browser’s per-host connection limit (6 for HTTP/1.1) no longer applies, but a single heavy stream can still head-of-line block others if the server sends large data frames. Prefer small, frequent frames over rare large ones.
Firewall and Load-Balancer Idle Timeouts Permalink to this section
Most load balancers (AWS ALB default: 60 s, HAProxy: depends on config) close idle TCP connections. Emit a heartbeat comment from the server to keep the connection alive:
: heartbeat
A line starting with : is a comment per the spec — it has no effect on the client but resets idle timers. Emit one every 15–25 seconds. The composable needs no special handling; the browser resets its own internal timer automatically.
Mitigation Checklist Permalink to this section
For a deeper treatment of reconnection on the client side see Error Handling & Reconnection UX.
Performance & Scale Considerations Permalink to this section
Connection Count Permalink to this section
Each useEventSource call opens one TCP connection. With HTTP/1.1, browsers enforce a per-origin limit of 6 concurrent connections (Chrome, Firefox). If a page mounts six components, each calling useEventSource to different endpoints on the same origin, the seventh will queue. Mitigations:
- Consolidate multiple data streams into one SSE endpoint using named events (
event:field) - Use HTTP/2 (limit is per origin negotiated with the server, effectively unlimited for practical use cases)
- For read-only feeds that many tabs share, consider a
SharedWorkerholding oneEventSourceand posting to all tabs viaMessageChannel— eliminates per-tab connections entirely
Memory Permalink to this section
Every SSE message that enters data.value is held in Vue’s reactive graph. A high-frequency stream (e.g., 100 msg/s) that accumulates into an array will exhaust memory within minutes. Always cap arrays:
const messages = ref<string[]>([])
// Inside the event handler:
messages.value = [newMsg, ...messages.value].slice(0, 500) // keep last 500
Alternatively use a circular buffer implemented as a shallowRef of a fixed-length Array:
const CAPACITY = 500
const buf = new Array(CAPACITY).fill(null)
let head = 0
const messages = shallowRef<(string | null)[]>([...buf])
function push(msg: string) {
buf[head % CAPACITY] = msg
head++
messages.value = [...buf] // trigger reactivity
}
CPU and Reactivity Overhead Permalink to this section
Vue’s reactivity tracks every property access. For deeply-nested objects coming in at high frequency, use shallowRef instead of ref so Vue only triggers when the top-level reference changes, not on every nested property mutation:
const data = shallowRef<PriceEvent | null>(null)
// Assign a new object each time (do not mutate in place)
data.value = JSON.parse(evt.data)
For mobile and background-tab handling, pause the stream when the document is hidden to eliminate CPU churn in background tabs:
import { onMounted, onUnmounted } from 'vue'
function usePausableEventSource(url: string) {
const { close, reconnect, status, data } = useEventSource(url)
function handleVisibility() {
if (document.hidden) close()
else reconnect()
}
onMounted(() => document.addEventListener('visibilitychange', handleVisibility))
onUnmounted(() => document.removeEventListener('visibilitychange', handleVisibility))
return { status, data }
}
Backpressure Permalink to this section
The browser’s EventSource has no built-in backpressure — it reads as fast as the server sends. If your server pushes faster than your transform function can process, you need server-side rate limiting. See Rate Limiting & Backpressure Handling for token-bucket and sliding-window approaches.
For CPU-bound transforms, defer heavy parsing off the event loop with a Worker:
// Heavy transform off the main thread
const worker = new Worker(new URL('./sse-parser.worker.ts', import.meta.url))
es.addEventListener('message', (evt) => {
worker.postMessage(evt.data)
})
worker.addEventListener('message', (e) => {
data.value = e.data as T
})
Validation & Debugging Permalink to this section
curl Smoke Test Permalink to this section
Before debugging the composable, confirm the server delivers correct SSE:
curl -N \
-H "Accept: text/event-stream" \
-H "Cache-Control: no-cache" \
https://your-api.example.com/api/stream
# -N = --no-buffer, critical for seeing chunks as they arrive
Expected output format:
: heartbeat
id: 1
event: price
data: {"symbol":"AAPL","price":189.42}
Chrome DevTools Permalink to this section
- Open Network tab → filter by
EventStream(the filter pill appears once any SSE connection opens) - Click the request → EventStream tab: shows each event with timestamp, type, data, and
lastEventId - The Timing tab shows time-to-first-byte; anything > 500 ms suggests a buffering proxy
Firefox DevTools Permalink to this section
Firefox shows SSE under Network → Media type. The panel is less detailed than Chrome’s but shows event count and payload size.
Structured Logging in the Composable Permalink to this section
Add an optional debug flag to emit structured logs:
// Inside connect(), after status.value = 'open':
if (options.debug) {
console.debug('[useEventSource] open', { url: resolveUrl(), attempt: attempts })
}
// Inside the error listener:
if (options.debug) {
console.debug('[useEventSource] error → reconnect in', delay, 'ms', {
attempt: attempts, maxRetries
})
}
Pass { debug: import.meta.env.DEV } so logs are stripped in production builds.
Testing the Composable with Vitest Permalink to this section
// useEventSource.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h } from 'vue'
import { useEventSource } from './useEventSource'
// Minimal EventSource mock
class MockEventSource {
static lastInstance: MockEventSource
listeners: Record<string, ((e: MessageEvent) => void)[]> = {}
readyState = 0
constructor(public url: string) { MockEventSource.lastInstance = this }
addEventListener(type: string, fn: (e: MessageEvent) => void) {
;(this.listeners[type] ??= []).push(fn)
}
dispatchEvent(type: string, data: string) {
this.listeners[type]?.forEach((fn) => fn({ data } as MessageEvent))
}
close() { this.readyState = 2 }
}
vi.stubGlobal('EventSource', MockEventSource)
describe('useEventSource', () => {
it('updates data ref on message', async () => {
const Wrapper = defineComponent({
setup() { return useEventSource('/test') },
render() { return h('div') },
})
const w = mount(Wrapper)
MockEventSource.lastInstance.dispatchEvent('message', 'hello')
await nextTick()
expect(w.vm.data).toBe('hello')
w.unmount()
expect(MockEventSource.lastInstance.readyState).toBe(2)
})
})
⚡ Production Directives
- Always register
onUnmountedcleanup inside the composable, not the component — leak prevention must not depend on callers remembering to callclose(). - Wrap
dataandstatusrefs inreadonly()before returning them; mutations from outside the composable will cause silent bugs. - Use
shallowReffor high-frequency (>10 msg/s) streams to reduce Vue's reactivity tracking overhead per message. - Set
X-Accel-Buffering: noon every SSE response and test the full proxy stack — localhost tests hide buffering bugs. - Cap any accumulated array at a fixed size in the composable to prevent unbounded memory growth in long-lived sessions.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why not use watchEffect to re-open the EventSource when the URL changes?
You can, but it adds complexity. A watchEffect re-runs when any reactive dependency changes, which means you need to close() the existing connection before opening a new one inside the effect, and make sure the cleanup function returned from watchEffect does that. It's safer to accept a () => string url factory and handle the watch inside the composable explicitly, or expose a reconnect(url) method. The pattern shown here keeps the composable's behaviour predictable: one URL, one connection.
Does EventSource automatically send Last-Event-ID on reconnect?
Yes — the browser's built-in reconnection (triggered by the connection dropping) will send a Last-Event-ID HTTP request header equal to the last id: value received. Your composable's custom reconnect (via new EventSource(url)) also benefits from this because the browser stores the last event ID per URL origin. However, if you construct a new EventSource with a different URL (e.g., including a query param), the stored ID is lost. Keep the URL stable and let the server use the header to resume replay from a buffer. See Event ID & Retry Mechanism Design for server-side replay patterns.
Can I use this composable with the Fetch API + ReadableStream instead of EventSource?
Yes, and it's necessary when you need custom request headers (e.g., Authorization: Bearer ...) because EventSource does not support custom headers. Replace the EventSource with a fetch() call inside the composable, pipe response.body through a TextDecoderStream, and implement the SSE parsing state machine (split on \n\n, extract data: lines) manually. The reactive wiring and onUnmounted cleanup pattern remain identical; only the transport layer changes. This is a common pattern for AI streaming APIs that require bearer tokens.
How do I share one SSE connection across multiple components?
Move the composable call into a Pinia store (or a module-level singleton) so all components read from the same reactive refs. Components subscribe to the store's refs rather than each opening their own EventSource. This is the right approach when many components on the same page need the same data feed — it cuts server-side connection load to one per client regardless of how many components are mounted.
Why does my composable reconnect on every hot-module-replacement (HMR) reload in dev?
Vite's HMR tears down and remounts components, which triggers onUnmounted and then setup() again. This is correct behaviour — the composable correctly closes and re-opens. In development, you can increase initialDelay to 0 for instant reconnect. In production this is a non-issue. If you see excessive reconnects in dev, check that your dev server is actually sending SSE (not being proxied through Vite's dev server without the correct proxy config for streaming).