---
title: "Real-time UI with WebSockets and server-sent events · vuejs.school"
description: "A live counter in Nuxt 4 two ways: a server-sent event stream read with useEventSource, and a Nitro WebSocket handler driven by useWebSocket, with reconnection."
canonical_url: "https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse"
meta:
  "og:description": "A live counter in Nuxt 4 two ways: a server-sent event stream read with useEventSource, and a Nitro WebSocket handler driven by useWebSocket, with reconnection."
  "og:image": "https://vuejs.school/_og/d/a_~UmVhbC10aW1lIFVJIHdpdGggV2ViU29ja2V0cyBhbmQgc2VydmVyLXNlbnQgZXZlbnRzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Real-time%2BUI%2Bwith%2BWebSockets%2Band%2Bserver-sent%2Bevents%2Cdescription_~QSBsaXZlIGNvdW50ZXIgaW4gTnV4dCA0IHR3byB3YXlzOiBhIHNlcnZlci1zZW50IGV2ZW50IHN0cmVhbSByZWFkIHdpdGggdXNlRXZlbnRTb3VyY2UsIGFuZCBhIE5pdHJvIFdlYlNvY2tldCBoYW5kbGVyIGRyaXZlbiBieSB1c2VXZWJTb2NrZXQsIHdpdGggcmVjb25uZWN0aW9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZWFsLXRpbWUtdWktd2l0aC13ZWJzb2NrZXRzLWFuZC1zc2Ui%2Cs_56YWZAkWs7afzcEA.png"
  "og:image:alt": "Real-time UI with WebSockets and server-sent events · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Real-time UI with WebSockets and server-sent events · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~UmVhbC10aW1lIFVJIHdpdGggV2ViU29ja2V0cyBhbmQgc2VydmVyLXNlbnQgZXZlbnRzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Real-time%2BUI%2Bwith%2BWebSockets%2Band%2Bserver-sent%2Bevents%2Cdescription_~QSBsaXZlIGNvdW50ZXIgaW4gTnV4dCA0IHR3byB3YXlzOiBhIHNlcnZlci1zZW50IGV2ZW50IHN0cmVhbSByZWFkIHdpdGggdXNlRXZlbnRTb3VyY2UsIGFuZCBhIE5pdHJvIFdlYlNvY2tldCBoYW5kbGVyIGRyaXZlbiBieSB1c2VXZWJTb2NrZXQsIHdpdGggcmVjb25uZWN0aW9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZWFsLXRpbWUtdWktd2l0aC13ZWJzb2NrZXRzLWFuZC1zc2Ui%2Cs_56YWZAkWs7afzcEA.png"
  "twitter:image:alt": "Real-time UI with WebSockets and server-sent events · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## Choose SSE or a WebSocket by who talks

`**Server-sent events**`

A plain HTTP response that never ends. The server pushes text events; the browser cannot send anything on the same connection. Reconnects by itself and works through every proxy and CDN that handles HTTP.

`**WebSockets**`

A separate protocol upgraded from HTTP. Both sides send messages, text or binary, at any time. Needs server support for the upgrade and a hosting platform that keeps the connection open.

Notifications, prices, build logs, "someone else edited this": the server talks, the browser listens, and SSE is enough. Chat, multiplayer cursors, a collaborative editor: the browser talks constantly too, and a WebSocket avoids a request per keystroke. A good test is whether the browser's messages are rare enough to be ordinary `POST` requests. If they are, SSE for the way down and `$fetch` for the way up is simpler and cheaper to host.

## Stream events from a Nitro route

Nitro, the server inside Nuxt, exposes `createEventStream` from h3. It sets the `text/event-stream` headers and gives you a `push` method. This route emits a counter once a second:

*server/api/ticks.get.ts*

```
export default defineEventHandler((event) => {
  const stream = createEventStream(event)
  let count = 0

  const timer = setInterval(() => {
    count++
    stream.push({
      event: 'tick',
      data: JSON.stringify({ count, at: new Date().toISOString() }),
    })
  }, 1000)

  stream.onClosed(async () => {
    clearInterval(timer)
    await stream.close()
  })

  return stream.send()
})
```

`push` accepts a string for an unnamed message, or an object with `event`, `data` and optionally `id` and `retry`. `data` must be a string, hence the `JSON.stringify`. `send()` returns the response and keeps it open.

The `onClosed` callback runs when the browser disconnects. Clearing the interval there is not optional: without it, every visitor who ever opened the page leaves a timer running on your server.

Open `http://localhost:3000/api/ticks` in a browser tab and you will see lines arrive: `event: tick`, `data: {...}`, a blank line, once a second. That is the whole protocol.

## Read the stream with EventSource

The browser API is `EventSource`. Wrapping it in a composable ties the connection to the component that needs it:

*app/composables/useTicks.ts*

```
type Tick = { count: number; at: string }

export function useTicks(url = '/api/ticks') {
  const tick = shallowRef<Tick | null>(null)
  const status = ref<'connecting' | 'open' | 'closed'>('connecting')
  let source: EventSource | undefined

  function open() {
    if (import.meta.server) return
    close()
    source = new EventSource(url)
    status.value = 'connecting'
    source.onopen = () => { status.value = 'open' }
    source.onerror = () => {
      status.value = source?.readyState === EventSource.CLOSED ? 'closed' : 'connecting'
    }
    source.addEventListener('tick', (e) => {
      tick.value = JSON.parse((e as MessageEvent).data)
    })
  }

  function close() {
    source?.close()
    source = undefined
    status.value = 'closed'
  }

  onScopeDispose(close)
  open()
  return { tick, status, open, close }
}
```

Four details carry the weight:

- `import.meta.server` stops the composable from opening a connection during server rendering, where there is no `EventSource` and no user.
- `status` starts as `'connecting'` so that the server's HTML matches the browser's first render, where `open()` has already set that value. Start it at `'closed'` and the two differ: Vue logs a hydration mismatch when it takes over the server's HTML.
- `addEventListener('tick', ...)` listens for the named event; unnamed messages would arrive on `onmessage` instead.
- `onScopeDispose(close)` closes the connection when the component unmounts. Forget it and navigating away leaves the stream open, the timer running, and the handler updating a ref nobody renders.

The `onerror` handler looks odd because `EventSource` reconnects on its own. A dropped connection puts it in the `CONNECTING` state and the browser retries after a delay, a few seconds by default or whatever the server sent in a `retry` field. Only a fatal failure, such as the server answering with an error status, moves it to `CLOSED`, and then it stays there.

Use it like any composable:

*app/pages/live.vue*

```
<script setup lang="ts">
const { tick, status } = useTicks()
</script>

<template>
  <p>Connection: {{ status }}</p>
  <p v-if="tick">Tick {{ tick.count }} at {{ tick.at }}</p>
</template>
```

## Replace the composable with useEventSource

VueUse packages the composable above, with the server guard and the cleanup built in, as `useEventSource`:

*app/pages/live.vue*

```
<script setup lang="ts">
import { useEventSource } from '@vueuse/core'

type Tick = { count: number; at: string }

const { data, status, error } = useEventSource('/api/ticks', ['tick'], {
  serializer: { read: (raw) => (raw ? (JSON.parse(raw) as Tick) : null) },
  autoReconnect: {
    retries: 10,
    delay: 2000,
    onFailed() { console.warn('Gave up on the tick stream') },
  },
})
</script>

<template>
  <p>Connection: {{ status }}</p>
  <p v-if="error">Stream error</p>
  <p v-if="data">Tick {{ data.count }} at {{ data.at }}</p>
</template>
```

The second argument lists the named events to listen for. `serializer.read` turns the raw string into your type, so `data` is a `Tick | null` and the template is typed. `status` is `CONNECTING`, `OPEN` or `CLOSED`; VueUse sets `CLOSED` on any error, even while the browser is still retrying, and `OPEN` again when it reconnects.

`autoReconnect` picks up where the browser stops: when the `EventSource` itself reaches `CLOSED`, VueUse closes it and opens a new one after `delay` milliseconds, up to `retries` times, then calls `onFailed`.

The connection closes when the component's scope is disposed. On the server `open()` does nothing and `status` stays at `CONNECTING`, the value the browser renders first, so this page hydrates without a mismatch.

## Add a WebSocket handler to Nitro

Now the two-way version: any client can increment the counter, and every client sees the new value. Nitro's WebSocket support is experimental and needs a flag:

*nuxt.config.ts*

```
export default defineNuxtConfig({
  nitro: {
    experimental: { websocket: true },
  },
})
```

The handler goes in `server/routes/_ws.ts` and is written with `defineWebSocketHandler`, as in the [Nitro WebSocket guide](https://v2.nitro.build/guide/websocket). A `peer` is one connected client:

*server/routes/\_ws.ts*

```
let count = 0

export default defineWebSocketHandler({
  open(peer) {
    peer.subscribe('counter')
    peer.send(JSON.stringify({ count }))
  },

  message(peer, message) {
    const text = message.text()
    if (text === 'ping') {
      peer.send('pong')
      return
    }
    if (text === 'increment') {
      count++
      const payload = JSON.stringify({ count })
      peer.send(payload)
      peer.publish('counter', payload)
    }
  },

  close(peer) {
    peer.unsubscribe('counter')
  },
})
```

`subscribe` and `publish` are the pub/sub pair from crossws, the library under Nitro's implementation. `publish` sends to every subscriber of the topic except the publishing peer, which is why the handler calls `peer.send` as well. The `ping` branch exists for the heartbeat you will add on the client in a moment. The route name `_ws` is a convention; any route file can accept a WebSocket upgrade.

The Node server preset runs this handler, and so does the development server. Serverless targets vary in whether they keep a connection open at all, so check your platform before relying on it in production.

## Connect a component with useWebSocket

The counter component connects to `/_ws`, shows the count and sends `increment` when clicked:

*app/components/LiveCounter.vue*

```
<script setup lang="ts">
import { useWebSocket } from '@vueuse/core'

const requestUrl = useRequestURL()
const wsUrl = `${requestUrl.protocol === 'https:' ? 'wss' : 'ws'}://${requestUrl.host}/_ws`

const { data, status, send, open } = useWebSocket<string>(wsUrl, {
  immediate: false,
  autoReconnect: {
    retries: 5,
    delay: 2000,
    onFailed() { console.warn('Could not reconnect') },
  },
  heartbeat: {
    message: 'ping',
    responseMessage: 'pong',
    interval: 30_000,
    pongTimeout: 5_000,
  },
})

onMounted(open)

const count = computed(() =>
  data.value ? (JSON.parse(data.value) as { count: number }).count : 0,
)
</script>

<template>
  <p>Socket: {{ status }}</p>
  <p>Count: {{ count }}</p>
  <button type="button" :disabled="status !== 'OPEN'" @click="send('increment')">
    Increment
  </button>
</template>
```

The component's setup also runs during server rendering, where there is no `window.location`. `useRequestURL()` gives the page's URL on the server and in the browser alike. The protocol switch matters: a page served over HTTPS may only open `wss://`.

`immediate: false` and `onMounted(open)` connect only once the component has mounted in the browser. `useWebSocket` starts at `CLOSED`, which is what the server renders; opening during setup would make the browser's first render say `CONNECTING`, and Vue would log a hydration mismatch.

`status` is `CONNECTING`, `OPEN` or `CLOSED`, and the button is disabled until the socket is open. `send` would buffer the message until then anyway, but a button that does nothing visible is worse than a disabled one. `data` holds the last message as a string; the computed parses it. Every time any client clicks, the server publishes, `data` changes, and every open tab shows the new count. Open two browser windows side by side and click in one to watch the other follow.

The heartbeat sends `ping` every 30 seconds and expects `pong` within 5. If nothing comes back, VueUse closes the socket and, because `autoReconnect` is on, opens a new one. That catches the common failure where a laptop sleeps or a proxy drops an idle connection without telling anyone, and the socket looks open while being dead. The `pong` replies are swallowed by VueUse and never reach `data`.

As with `useEventSource`, the connection closes when the component unmounts; `useWebSocket` also closes it on `beforeunload`.

## Spread out reconnects with backoff

Both composables retry with a fixed `delay` in VueUse 13.9. A fixed delay is fine for a handful of users. If a server restart disconnects a few thousand clients at once, you want them to come back spread out, which means a delay that grows with each attempt, called exponential backoff. You can build it from the hooks VueUse gives you. In `LiveCounter.vue`, replace the `useWebSocket` call with this one, copy the `heartbeat` option across, and keep `onMounted(open)`:

```
let attempt = 0
let stopped = false

const { data, status, send, open } = useWebSocket<string>(wsUrl, {
  immediate: false,
  autoReconnect: false,
  onConnected() { attempt = 0 },
  onDisconnected() {
    if (stopped) return
    const wait = Math.min(30_000, 1000 * 2 ** attempt++)
    setTimeout(() => { if (!stopped) open() }, wait)
  },
})

onScopeDispose(() => { stopped = true })
```

The delay doubles from one second to a cap of 30 and resets on a successful connection. The `stopped` flag keeps an unmounted component from reopening its socket: `onDisconnected` checks it when the close arrives, and the timer checks it again in case the component unmounted while a retry was waiting.

For SSE, the browser's own reconnection already applies a delay, and the server can tune it with the `retry` field on any pushed message.

**Check it stuck**3 questions from the Real-time with WebSockets and SSE quiz.

[**Practice now**](https://vuejs.school/learn/data/real-time-with-websockets-and-sse?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Deep dive · 6 min read**Streaming AI responses: fetch, ReadableStream and the AI SDK**](https://vuejs.school/community/blog/streaming-ai-responses-fetch-readablestream-and-the-ai-sdk) [Tutorial · 8 min read**Authentication basics: tokens, cookies and where to keep them**](https://vuejs.school/community/blog/authentication-basics-tokens-cookies-and-where-to-keep-them)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-09-11","description":"A live counter in Nuxt 4 two ways: a server-sent event stream read with useEventSource, and a Nitro WebSocket handler driven by useWebSocket, with reconnection.","name":"Real-time UI with WebSockets and server-sent events","url":"https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse","breadcrumb":{"@id":"https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-09-11","description":"A live counter in Nuxt 4 two ways: a server-sent event stream read with useEventSource, and a Nitro WebSocket handler driven by useWebSocket, with reconnection.","headline":"Real-time UI with WebSockets and server-sent events","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~UmVhbC10aW1lIFVJIHdpdGggV2ViU29ja2V0cyBhbmQgc2VydmVyLXNlbnQgZXZlbnRzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Real-time%2BUI%2Bwith%2BWebSockets%2Band%2Bserver-sent%2Bevents%2Cdescription_~QSBsaXZlIGNvdW50ZXIgaW4gTnV4dCA0IHR3byB3YXlzOiBhIHNlcnZlci1zZW50IGV2ZW50IHN0cmVhbSByZWFkIHdpdGggdXNlRXZlbnRTb3VyY2UsIGFuZCBhIE5pdHJvIFdlYlNvY2tldCBoYW5kbGVyIGRyaXZlbiBieSB1c2VXZWJTb2NrZXQsIHdpdGggcmVjb25uZWN0aW9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZWFsLXRpbWUtdWktd2l0aC13ZWJzb2NrZXRzLWFuZC1zc2Ui%2Cs_56YWZAkWs7afzcEA.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/real-time-ui-with-websockets-and-sse#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Real-time UI with WebSockets and server-sent events","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~UmVhbC10aW1lIFVJIHdpdGggV2ViU29ja2V0cyBhbmQgc2VydmVyLXNlbnQgZXZlbnRzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Real-time%2BUI%2Bwith%2BWebSockets%2Band%2Bserver-sent%2Bevents%2Cdescription_~QSBsaXZlIGNvdW50ZXIgaW4gTnV4dCA0IHR3byB3YXlzOiBhIHNlcnZlci1zZW50IGV2ZW50IHN0cmVhbSByZWFkIHdpdGggdXNlRXZlbnRTb3VyY2UsIGFuZCBhIE5pdHJvIFdlYlNvY2tldCBoYW5kbGVyIGRyaXZlbiBieSB1c2VXZWJTb2NrZXQsIHdpdGggcmVjb25uZWN0aW9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZWFsLXRpbWUtdWktd2l0aC13ZWJzb2NrZXRzLWFuZC1zc2Ui%2Cs_56YWZAkWs7afzcEA.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~UmVhbC10aW1lIFVJIHdpdGggV2ViU29ja2V0cyBhbmQgc2VydmVyLXNlbnQgZXZlbnRzIMK3IHZ1ZWpzLnNjaG9vbA%2Cc_OgCard.takumi%2Ctitle_Real-time%2BUI%2Bwith%2BWebSockets%2Band%2Bserver-sent%2Bevents%2Cdescription_~QSBsaXZlIGNvdW50ZXIgaW4gTnV4dCA0IHR3byB3YXlzOiBhIHNlcnZlci1zZW50IGV2ZW50IHN0cmVhbSByZWFkIHdpdGggdXNlRXZlbnRTb3VyY2UsIGFuZCBhIE5pdHJvIFdlYlNvY2tldCBoYW5kbGVyIGRyaXZlbiBieSB1c2VXZWJTb2NrZXQsIHdpdGggcmVjb25uZWN0aW9uLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNyBtaW4gcmVhZA%2Ctint_sky%2Cp_Ii9jb21tdW5pdHkvYmxvZy9yZWFsLXRpbWUtdWktd2l0aC13ZWJzb2NrZXRzLWFuZC1zc2Ui%2Cs_56YWZAkWs7afzcEA.png"}]}
```