Hello there! This article is adapted from my talk at MadVue 2026, the amazing Vue conference that took place in Madrid, Spain. Make sure to check out this page for the slides, all the articles and some pics!
Today we'll explore how to power up your Nuxt and Vue applications with real-time features, using:
- Server-Sent Events
- WebSockets
- and WebRTC
By the end of these three articles, you'll know when to implement each one and which composables, libraries, and services can help you do so.
Plus, we'll take a peek at real-time databases and local-first solutions.
There's a lot to cover! But allow me to start with a little story... from the dark ages of jQuery.
The Dashboard That Didn't Move
A long, long time ago in a hosting company far away, the boss needed a dashboard: he wanted to see the number of visits, support tickets, and sales, all in one place. Easy enough, right?
So we go ahead, build the dashboard, and show it to him. He looked at the screen for what it felt like an eternity, in complete silence, with a face of scrutiny. Then, he finally said:
Nice! I like it. Yeah, I like the charts, all the numbers are there but... there's a problem. It's not moving!
He was expecting live updates! That wasn't explicit in the spec, but it was so obvious in hindsight. So young me said:
Well, yeah, it's not real-time yet.
And immediately my co-worker added:
Unless... you hit refresh really fast over and over again!
And I know, this sounds ridiculous. If your users ask for real-time features please don't ask them to just press F5 over and over again. Your boss? Maybe... but not your users!
However, that idea isn't too far from what we ended up implementing. Because what we implemented back then was polling.
Polling: Real-Time? Not Really
What is polling? Well, the idea is simple.
You need to update some data on the screen. In this case, numbers on the dashboard. So you fire a request to the API, in the background. Your server gets the request and returns fresh data, so you can use it to re-render parts of the UI.
And you do that over and over again, at regular intervals. For example, every five seconds. The server always returns data, even if nothing changed from the last time you asked.
This is the code I used back in the day. jQuery, the memories!
function poll() {
$.get('/api/stats', function (data) {
updateDashboard(data)
})
}
setInterval(poll, 5000)
Polling was fine back then, and even today it is acceptable for certain scenarios, but... it's definitely not great in general. What are the problems with polling? Why is it an inefficient technique? Well, three main reasons.
- You never achieve true real-time. If you poll every five seconds and something happens on second two (a new sale, for example), you need to wait until the next poll round to get that fresh data to the client. Most of the time, you are behind. Of course, that's not real-time.
- Nothing guarantees the order of the responses. Let's say that, to prevent that delay, you decide to poll every second. Well, you know how it goes with async requests: if you make requests A, B, C nothing stops the server from receiving responses A, C, B. And that can glitch your UI, especially if you're expecting numbers that always go up, like the number of visits.
- The strain on the server. But more importantly, there's this reason, the work you are forcing the server to do every time a request is made. Think about the lifecycle of a single API request: the server has to accept the connection, perhaps run authentication and verify permissions, control caches or query the database... and more. With polling, you're doing all of that for every single request, for every single connected user, even if there's nothing new to report.
Scale that to thousands of users and your server might struggle. Your app goes viral? Well, you got yourself a mini self-inflicted DDoS attack.
Polling is like a kid in the backseat of a car asking, "Are we there yet? Are we there yet? Are we there yet?" What do you do in those situations? You say: "I will let you know when we arrive, Timmy."
Well, the same applies here. We need to flip the dynamic: instead of the client asking for data over and over again, we can trust the server to send it data whenever it has something new to say.
And that's the idea behind Server-Sent Events.
Server-Sent Events
Server-Sent Events (SSE for short) work with a classic HTTP request, but with one big difference: the connection stays open. It's a pipe through which the server can send events over time, whenever it has something new to report.
Need to send a notification? Send an event. New stats for the dashboard? Send an event. Need to display the positions of vehicles in a map, in real-time? Again, you can send events to do so.
But wait, what are these "events"? Well, they're just little pieces of text.
The format is key-value pairs, and the only required key is data:
data: Hello, MadVue!
The value of data can be any string, so we can send stringified JSON:
data: {"city": "Madrid", "conference": "MadVue"}
Plus, we have a couple more standard keys:
event: greeting
id: 123
retry: 5000
data: {"city": "Madrid", "conference": "MadVue"}
eventis the name of the event. Think of it like a channel, such as "notifications" or "purchases". In the client code, you can decide which channels to listen to, or whether to listen to all of them.idcan be whatever you want, you can handle it on your own. This will be useful for the auto-reconnect feature that we'll explore shortly.retryis a number in milliseconds that indicates how long to wait before reconnecting if the connection drops. Again, we'll check auto-reconnect in a moment.
Supported by Every Browser
All browsers support SSE, they know how to handle these long-lived connections and offer the EventSource native method to listen to the incoming events.
In fact, Chrome, Firefox and Safari have supported SSE since 2010-2011... a long time ago! So why didn't SSE become more popular? Probably because Internet Explorer never added support, and neither did the original Edge that shipped in 2015 on Microsoft's own EdgeHTML engine. It wasn't until January 2020, when Edge switched to Chromium, that we could finally say all major browsers supported SSE.
Thankfully, we can go ahead and use them today without any worries!
How to Implement Server-Sent Events
Server-Side
Let's take a look at how we can implement SSE in our application, starting with the server-side code. If you are using Nuxt (you should, it's fantastic!) you are lucky, because it comes with SSE support thanks to Nitro.
To create an SSE endpoint, define an event handler in a server route as you always do. Then, use the createEventStream composable to create a stream. The resulting object will have two important methods: send will open the pipe, and push will send events through it.
For example, here we're pushing the string "Hello!" every second:
export default defineEventHandler((event) => {
const stream = createEventStream(event)
setInterval(
() => stream.push('Hello!'),
1000
)
return stream.send()
})
In other words, every second we're sending this event:
data: Hello!
But, as we mentioned before, we're not limited to regular intervals. We can push events whenever we need. For example, every time there's a purchase, we can push an event with the purchase information. A clean way to do that is with an event bus:
import bus from '~/server/utils/bus'
export default defineEventHandler((event) => {
const stream = createEventStream(event)
bus.hook('purchase', (purchase) => {
stream.push(JSON.stringify(purchase))
})
return stream.send()
})
If you want more control, push accepts an object where you can define the event name, id, and retry, all those keys we mentioned earlier:
import bus from '~/server/utils/bus'
export default defineEventHandler((event) => {
const stream = createEventStream(event)
bus.hook('purchase', (purchase) => {
stream.push({
event: 'purchase',
id: 42,
retry: 3000,
data: JSON.stringify(purchase)
})
})
return stream.send()
})
As you can imagine, this sends down the pipe something like this:
event: purchase
id: 42
retry: 3000
data: {"total": 14, "product": "Doom Eternal"}
Last but not least, don't forget to close the connection to prevent memory leaks. When the client disconnects, clean up your listeners:
import bus from '~/server/utils/bus'
export default defineEventHandler((event) => {
const stream = createEventStream(event)
const off = bus.hook('purchase', (purchase) => {
stream.push({
event: 'purchase',
id: 42,
retry: 3000,
data: JSON.stringify(purchase)
})
})
stream.onClosed(async () => {
off()
await stream.close()
})
return stream.send()
})
Client-Side
On the client side, the browser gives you a built-in interface to handle Server-Sent Events: EventSource.
// Built-in browser interface
const eventSource = new EventSource('/sse')
eventSource.onmessage = (event) => {
// Do something...
}
Heads up: onmessage only fires for unnamed message events. If the server sends a named event like event: purchase, you'll need eventSource.addEventListener('purchase', ...) instead.
But to make things much simpler in Vue, we can use a composable from the fantastic VueUse: useEventSource.
<script setup>
import { useEventSource } from '@vueuse/core'
const { data } = useEventSource('/sse')
</script>
<template>
<div>{{ data }}</div>
</template>
As you can see, it takes the URL as a parameter, and we can destructure data from it. data is a reactive shallow ref. That means:
- If you print it in your template, the component will automatically re-render whenever a new event comes in.
- The same applies to computed properties: they'll be re-computed whenever data changes.
- Of course, you can watch it, and use it as you would with any other ref.
For more control, useEventSource accepts a couple of optional params:
- We can pass an array of event names we want to listen to.
- And an options object. In this case, we're setting a deserializer: if the server returns stringified JSON, this is how you automatically parse it.
<script setup>
import { useEventSource } from '@vueuse/core'
const { data } = useEventSource('/sse',
// Subscribe to this array of events
['purchase'],
// Use these options
{ serializer: { read: JSON.parse } }
)
</script>
<template>
<div>{{ data?.total }}</div>
</template>
And there are a few more things we can destructure: status, event, error, and lastEventId, which are pretty handy:
<script setup>
import { useEventSource } from '@vueuse/core'
const { data, status, event, error, lastEventId } = useEventSource('/sse',
['purchase'],
{ serializer: { read: JSON.parse } }
)
</script>
<template>
<div>Status: {{ status }}</div>
<div>Event: {{ event }} - Last Event ID: {{ lastEventId }}</div>
<div>{{ data?.total }}</div>
</template>
What Happens When the Connection Drops?
Now, one more thing about SSE. What if you're checking a live feed and you lose access to the internet?
Well, the client attempts to reconnect after retry milliseconds, automatically. And when it does, it sends a special Last-Event-ID header with the last ID it received.
Server-Side
From the server, you can read that header and replay the events the client missed, or handle the situation however you see fit.
export default defineEventHandler((event) => {
const stream = createEventStream(event)
const lastEventId = getHeader(event, 'Last-Event-ID')
if (lastEventId) {
const missed = getEventsSince(lastEventId)
missed.forEach(e => stream.push(e))
}
return stream.send()
})
Client-Side
On the client side, you can customize the reconnection behavior with the autoReconnect option:
// The client can customize the reconnection behavior
const { data } = useEventSource('/api/sse', [], {
autoReconnect: {
retries: 5,
delay: 2000,
onFailed: () => console.log('Could not reconnect'),
}
})
SSE Are Great! But...
SSE are great. Perhaps not as popular as other solutions, but fantastic for notifications, live dashboards, live tickers, and more.
But they are a one-way street. It's like a radio: you tune to a station, you receive the signal, but you can't send anything back.
What if you need to? What if you want the client to send data to the server using the same pipe? Well, in that case, you can use WebSockets.
And that's exactly what we'll cover in Part 2: WebSockets and Real-Time Databases.
See you there!



