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.

Vue EventSource composable lifecycle and data-flow Diagram showing how a Vue component calls useEventSource, which owns an EventSource instance. Events flow from the SSE server through the browser network layer into the composable's reactive refs, which drive template rendering. onUnmounted closes the connection. SSE Server text/event-stream Network Proxy / CDN X-Accel-Buffering: no useEventSource() EventSource instance status: Ref<string> data: Ref<T | null> error: Ref<Event | null> Vue Component <template> {{ status }} {{ data }} <script setup> const { data, status } = useEventSource(url) onUnmounted → .close() chunks events reactive onerror → backoff → new EventSource()
Data flow from SSE server through the network layer into a Vue composable's reactive refs, with onUnmounted teardown and backoff reconnection.

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:

  1. Creates an EventSource inside the composable scope (not inside a lifecycle hook — the composable itself is called synchronously from setup())
  2. Attaches ref-backed event listeners so Vue’s reactivity system picks up every update
  3. Registers an onUnmounted callback to call es.close(), preventing ghost connections after the component is destroyed
  4. 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 SharedWorker holding one EventSource and posting to all tabs via MessageChannel — 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

  1. Open Network tab → filter by EventStream (the filter pill appears once any SSE connection opens)
  2. Click the request → EventStream tab: shows each event with timestamp, type, data, and lastEventId
  3. 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 onUnmounted cleanup inside the composable, not the component — leak prevention must not depend on callers remembering to call close().
  • Wrap data and status refs in readonly() before returning them; mutations from outside the composable will cause silent bugs.
  • Use shallowRef for high-frequency (>10 msg/s) streams to reduce Vue's reactivity tracking overhead per message.
  • Set X-Accel-Buffering: no on 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).

Deep Dives