Creating a Vue Composable for Server-Sent Events Permalink to this section

Part of Vue EventSource Composables.

When a developer wires EventSource directly inside a Vue component, three failure modes appear almost immediately: the connection leaks after the component unmounts, named event types never fire because the listener was never registered, and there is no reactive surface for the rest of the component tree to observe status changes. The fix is a single, tested composable — useServerSentEvents — that encapsulates the entire lifecycle in one reusable function.

Symptom & Developer Intent Permalink to this section

The typical starting point looks like this:

// ❌ Common mistake: EventSource lives outside Vue's lifecycle
const es = new EventSource('/api/stream')
es.onmessage = (e) => { data.value = e.data }
// nothing closes es when the component unmounts

Symptoms that surface immediately:

  • Console shows duplicate messages after navigating away and back — the old EventSource is still open.
  • Named events (event: notification\n) are silently dropped because only onmessage is registered, which only fires for events without an event: field.
  • No reactive status ref means the template cannot show a “Reconnecting…” badge.
  • Memory profilers show EventSource objects accumulating in the background.

The developer intent is: one composable, called in setup(), that returns reactive data, status, and error refs and cleans up automatically.

Root Cause Analysis Permalink to this section

EventSource is a browser-native object, not a Vue-aware primitive. Vue’s reactivity system and component lifecycle are opt-in; they do not intercept object allocation or garbage-collect closures automatically.

Why connections leak. When a component unmounts, Vue tears down its reactive effect scope and DOM subtree, but EventSource holds an open TCP connection via the browser’s HTTP/2 or keep-alive machinery. The connection is tied to the EventSource object reference. If that reference is captured in a closure outside Vue’s lifecycle hooks, the browser never knows to close it. The server continues streaming and each navigation cycle opens a new connection — confirmed by checking DevTools → Network → EventStream with the filter eventsource.

Why named events are silent. The WHATWG SSE spec dispatches events using dispatchEvent on the EventSource object. onmessage is a shorthand for the unnamed message event type only. Any event with an event: line dispatches under that name. Developers must call addEventListener('notification', handler) explicitly for each event type, or use a single message listener and inspect event.type.

Why reconnection needs explicit control. The browser’s built-in auto-reconnect uses the retry: field from the server (defaulting to 3 000 ms). But this is unconditional — it reconnects even after a 401 (authentication expired) or 404 (endpoint gone). Production composables must intercept onerror, inspect readyState, close on permanent errors, and implement back-off for transient ones. See the Event ID & Retry Mechanism Design guide for the full retry protocol.

Step-by-Step Resolution Permalink to this section

Step 1 — Scaffold the composable signature Permalink to this section

Create src/composables/useServerSentEvents.ts. The signature accepts a URL (reactive or plain string), an optional list of named event types to subscribe to, and an options bag for reconnection control.

// src/composables/useServerSentEvents.ts
import {
  ref,
  computed,
  watch,
  onUnmounted,
  toValue,
  type MaybeRefOrGetter,
} from 'vue'

export type SSEStatus = 'connecting' | 'open' | 'closed' | 'error'

export interface UseSSEOptions {
  /** Named event types to subscribe to (in addition to the default 'message'). */
  events?: string[]
  /** Credentials mode forwarded to EventSource. Default: 'same-origin'. */
  withCredentials?: boolean
  /** Maximum automatic reconnect attempts. 0 = browser default (unlimited). */
  maxRetries?: number
  /** Base delay (ms) for exponential back-off. Default 1 000. */
  retryDelay?: number
  /** Called on every raw MessageEvent before updating data. */
  onMessage?: (event: MessageEvent) => void
}

export function useServerSentEvents<T = string>(
  url: MaybeRefOrGetter<string>,
  options: UseSSEOptions = {},
) {
  const {
    events = [],
    withCredentials = false,
    maxRetries = 5,
    retryDelay = 1_000,
    onMessage,
  } = options

  const data    = ref<T | null>(null)
  const status  = ref<SSEStatus>('connecting')
  const error   = ref<Event | null>(null)
  const retries = ref(0)

  let es: EventSource | null = null
  let retryTimer: ReturnType<typeof setTimeout> | null = null

  // --- exposed ---
  return { data, status, error, retries, close, reconnect }
}

Step 2 — Open, bind, and close the EventSource Permalink to this section

Add the internal open() and close() functions. The handler list is built once per open call so the same function references can be removed cleanly.

  function close() {
    if (retryTimer) { clearTimeout(retryTimer); retryTimer = null }
    if (es) {
      es.close()       // sets readyState = CLOSED; no further events fire
      es = null
    }
    status.value = 'closed'
  }

  function open() {
    close()            // guard: close any existing connection first

    const resolvedUrl = toValue(url)
    if (!resolvedUrl) return

    status.value = 'connecting'
    es = new EventSource(resolvedUrl, { withCredentials })

    es.addEventListener('open', () => {
      status.value = 'open'
      retries.value = 0   // reset back-off counter on successful open
      error.value = null
    })

    // Default (unnamed) message events
    es.addEventListener('message', (evt: MessageEvent) => {
      data.value = tryParse<T>(evt.data)
      onMessage?.(evt)
    })

    // Named event types the caller declared
    for (const name of events) {
      es.addEventListener(name, (evt: MessageEvent) => {
        data.value = tryParse<T>(evt.data)
        onMessage?.(evt)
      })
    }

    es.addEventListener('error', (evt: Event) => {
      error.value = evt
      status.value = 'error'
      // readyState CLOSED (2) means the browser already gave up
      if (es?.readyState === EventSource.CLOSED) {
        close()
        scheduleRetry()
      }
      // readyState CONNECTING (0) means the browser is already retrying —
      // do not double-retry; just update status
    })
  }

  function tryParse<T>(raw: string): T {
    try { return JSON.parse(raw) as T }
    catch { return raw as unknown as T }
  }

Step 3 — Implement exponential back-off retry Permalink to this section

The browser’s built-in retry fires immediately on every error, even a 401. Override it by closing in onerror and scheduling a managed retry with back-off.

  function scheduleRetry() {
    if (maxRetries > 0 && retries.value >= maxRetries) {
      status.value = 'closed'
      return
    }
    const delay = retryDelay * 2 ** retries.value   // 1s, 2s, 4s, 8s …
    retries.value += 1
    status.value = 'connecting'
    retryTimer = setTimeout(open, delay)
  }

  function reconnect() {
    retries.value = 0   // manual reconnect resets the counter
    open()
  }

For the full specification of retry: field handling and Last-Event-ID see Event ID & Retry Mechanism Design.

Step 4 — React to URL changes and clean up on unmount Permalink to this section

Use watch to reopen the connection whenever the URL ref changes (useful for authenticated streams where the token is in the query string). Call close() in onUnmounted — this is the critical step that prevents leaks.

  // Re-open whenever the URL changes (supports reactive URLs)
  watch(
    () => toValue(url),
    (newUrl) => { if (newUrl) open() },
    { immediate: true },
  )

  onUnmounted(close)   // ← THE essential cleanup; never skip this

Step 5 — Consume the composable in a component Permalink to this section

<!-- src/components/LiveFeed.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { useServerSentEvents } from '@/composables/useServerSentEvents'

const props = defineProps<{ channel: string }>()

const streamUrl = computed(() => `/api/events?channel=${props.channel}`)

const { data, status, error, retries, reconnect } = useServerSentEvents<{
  text: string
  ts: number
}>(streamUrl, {
  events: ['notification', 'heartbeat'],
  maxRetries: 6,
  retryDelay: 500,
})
</script>

<template>
  <div>
    <span :class="`badge badge--${status}`">{{ status }}</span>
    <p v-if="data">{{ data.text }} ({{ new Date(data.ts).toLocaleTimeString() }})</p>
    <p v-if="error" class="error">
      Connection error — attempt {{ retries }}.
      <button @click="reconnect">Retry now</button>
    </p>
  </div>
</template>

Step 6 — Pass Last-Event-ID for resumable streams Permalink to this section

The browser automatically sends the Last-Event-ID HTTP header on reconnect when EventSource managed the connection. When you implement custom back-off (step 3) you close the EventSource and reopen it — the browser loses the last ID. Track it manually:

  // Inside useServerSentEvents, after the data ref declarations:
  const lastEventId = ref<string | null>(null)

  // In the 'message' listener:
  es.addEventListener('message', (evt: MessageEvent) => {
    if (evt.lastEventId) lastEventId.value = evt.lastEventId
    data.value = tryParse<T>(evt.data)
  })

  // In open(), append the ID to the URL if present:
  function open() {
    close()
    let resolvedUrl = toValue(url)
    if (!resolvedUrl) return
    if (lastEventId.value) {
      const sep = resolvedUrl.includes('?') ? '&' : '?'
      resolvedUrl += `${sep}lastEventId=${encodeURIComponent(lastEventId.value)}`
    }
    es = new EventSource(resolvedUrl, { withCredentials })
    // … rest of setup
  }

The server reads the query param and replays missed events. This replicates the browser’s native Last-Event-ID header behaviour that is lost when the EventSource is replaced. See Idempotent Event ID Generation for server-side ID schemes.

Validation & Monitoring Permalink to this section

Verify cleanup with DevTools Permalink to this section

  1. Open DevTools → Network, filter by eventsource (type filter pill).
  2. Mount the component. One SSE connection appears with status 101 or 200 (pending).
  3. Navigate away (or unmount the component). The connection row status changes to Cancelled within one tick.
  4. Navigate back. Exactly one new connection appears — not two.

Verify named events fire Permalink to this section

# Emit a named event from your server and watch the console
curl -N -H "Accept: text/event-stream" http://localhost:3000/api/events
# Server should send:
# event: notification
# data: {"text":"hello","ts":1718000000000}
#

In the component, add a temporary onMessage logger:

useServerSentEvents(streamUrl, {
  events: ['notification'],
  onMessage: (e) => console.log('[SSE]', e.type, e.data),
})

Confirm the console prints [SSE] notification {"text":"hello","ts":...}.

Unit-test stub with Vitest Permalink to this section

// src/composables/__tests__/useServerSentEvents.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, ref } from 'vue'
import { useServerSentEvents } from '../useServerSentEvents'

// Mock browser EventSource
class MockEventSource extends EventTarget {
  static CONNECTING = 0; static OPEN = 1; static CLOSED = 2
  readyState = MockEventSource.CONNECTING
  close() { this.readyState = MockEventSource.CLOSED }
  constructor(public url: string) { super() }
}
vi.stubGlobal('EventSource', MockEventSource)

describe('useServerSentEvents', () => {
  it('sets status to closed after unmount', async () => {
    const wrapper = mount(defineComponent({
      setup() { return useServerSentEvents('/api/events') },
      template: '<div />',
    }))
    expect(wrapper.vm.status).toBe('connecting')
    await wrapper.unmount()
    expect(wrapper.vm.status).toBe('closed')
  })
})

⚡ Production Directives

  • Always call onUnmounted(close) — skipping it leaks one TCP connection per component mount cycle.
  • Close the EventSource yourself before scheduling retry; the browser's unconditional reconnect will race your timer if you don't.
  • Register named event types with addEventListener, not onmessage — unnamed events only fire on the message type.
  • Implement exponential back-off with a configurable ceiling; retrying at 1 s on a 401 will hammer auth servers under flapping network conditions.
  • Track lastEventId manually when your composable replaces the EventSource object on reconnect, or the server cannot replay missed events.

Verification Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Why use a composable instead of a Pinia store action?

A composable co-locates the EventSource lifecycle with the component that owns it. When the component unmounts, the connection closes automatically via onUnmounted. A Pinia action has no automatic cleanup hook — you must manually call a cleanup action when the component unmounts, which is easy to forget. Use a composable for component-scoped streams; use Pinia (with a composable inside the store setup) only when you need the stream to outlive any single component, for example a global notification feed. See State-Management Integration for SSE for that pattern.

Does EventSource support request headers like Authorization?

No. The browser's EventSource constructor does not accept custom request headers — the only option it exposes is withCredentials. For bearer-token auth, pass the token as a query parameter (/api/events?token=…) and validate it server-side, or use cookie-based auth with withCredentials: true. See Authenticating SSE Streams with Tokens & Cookies for tradeoffs. If you need header-based auth, use fetch with a ReadableStream instead of EventSource.

How do I handle multiple named event types without duplicating data refs?

Pass all event names in the events array option. Inside the composable, a single shared data ref is updated by every listener, and the raw MessageEvent (including event.type) is forwarded to your onMessage callback. If you need per-type state, maintain a Record<string, Ref> map inside the composable and expose it, or call useServerSentEvents twice with different URLs/event filters — each call owns its own EventSource and cleanup.

What happens when the page becomes hidden (background tab)?

Browsers do not suspend EventSource connections when a tab is hidden, but mobile OSes may kill the connection after 30–60 s of backgrounding. The composable's onerror handler will catch the drop and schedule a retry. For proactive management — pausing the stream when hidden and resuming on focus — combine this composable with the Page Visibility API pattern: call close() on visibilitychange to hidden, then reconnect() on visible.

Can I use this composable with Vue 2?

No. The composable uses the Composition API (ref, watch, onUnmounted, toValue) and MaybeRefOrGetter, all of which require Vue 3. toValue specifically was added in Vue 3.3. For Vue 2, you would need the @vue/composition-api plugin and would lose toValue — replace it with a computed getter and a manual isRef check.