Building Real-Time Features in a Nuxt App
Real-time updates — notifications that appear without refreshing, collaborative cursors, live activity feeds — are the features that make products feel alive. They're also where a lot of startups over-engineer early and under-engineer when it actually matters.
Here's the practical breakdown of how to implement real-time features in a Nuxt application.
Do you actually need real-time?
Before the implementation, ask: does this feature require real-time, or just near-real-time?
Requires actual real-time (<500ms):
- Collaborative editing (Google Docs style)
- Multiplayer cursors
- Live auction bids
- Trading/pricing data
Near-real-time is sufficient (1-30 seconds):
- Notification counts
- Activity feeds
- Comment threads
- Background job status
The difference matters because the implementation complexity is very different.
Option 1: Polling (simple, often enough)
Before reaching for WebSockets, consider polling. An API call every 10-30 seconds catches updates that are near-real-time with no additional infrastructure.
// composables/usePolling.ts
export function usePolling(fn: () => Promise<void>, intervalMs: number) {
let interval: NodeJS.Timeout | null = null
function start() {
fn() // Run immediately
interval = setInterval(fn, intervalMs)
}
function stop() {
if (interval) clearInterval(interval)
}
onMounted(start)
onUnmounted(stop)
}
<script setup>
const { data: notifications } = useFetch('/api/notifications')
usePolling(() => refreshNuxtData('notifications'), 30_000)
</script>
When polling is fine: Notification counts, activity feeds, dashboard metrics where a 30-second delay is acceptable.
When polling isn't fine: Collaborative features where you can't tolerate lag, or high-frequency updates where polling would generate too many requests.
Option 2: Server-Sent Events (one-way, simple)
SSE is a native web feature: the server sends a stream of events over a persistent HTTP connection. It's simpler than WebSockets because it's one-directional (server → client).
// server/api/events.get.ts
export default defineEventHandler((event) => {
setHeader(event, 'Content-Type', 'text/event-stream')
setHeader(event, 'Cache-Control', 'no-cache')
setHeader(event, 'Connection', 'keep-alive')
// Send an event every time something changes in your data layer
// This is simplified — in practice you'd subscribe to a pub/sub channel
const interval = setInterval(() => {
event.node.res.write(`data: ${JSON.stringify({ type: 'ping' })}\n\n`)
}, 15000)
event.node.req.on('close', () => {
clearInterval(interval)
})
})
// composables/useSSE.ts
export function useSSE(url: string) {
const eventSource = ref<EventSource | null>(null)
function connect() {
eventSource.value = new EventSource(url)
return eventSource.value
}
onUnmounted(() => {
eventSource.value?.close()
})
return { connect }
}
When SSE works well: Live notifications, status updates, one-way data streams.
Option 3: Supabase Realtime (recommended for most startups)
If you're already using Supabase as your database, Supabase Realtime is by far the simplest path to real-time features. It gives you WebSocket-based subscriptions to database changes with no additional infrastructure.
// composables/useRealtimeComments.ts
export function useRealtimeComments(postId: string) {
const supabase = useSupabaseClient()
const comments = ref<Comment[]>([])
async function fetchComments() {
const { data } = await supabase
.from('comments')
.select('*')
.eq('post_id', postId)
.order('created_at', { ascending: true })
comments.value = data ?? []
}
onMounted(() => {
fetchComments()
supabase
.channel(`comments:${postId}`)
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'comments', filter: `post_id=eq.${postId}` },
(payload) => {
comments.value.push(payload.new as Comment)
}
)
.subscribe()
})
return { comments }
}
This pattern:
- Fetches existing data on mount
- Subscribes to database changes
- Appends new comments as they arrive — no polling, no manual refresh
Use Supabase Realtime when: You're on Supabase and need real-time for relatively standard data patterns (new rows, updated fields, deleted records).
Option 4: WebSockets for custom real-time logic
When you need bidirectional real-time communication and Supabase Realtime doesn't fit, WebSockets are the right tool. Nuxt handles WebSocket upgrades natively through Nitro.
This is the right choice for:
- Collaborative editing
- Multiplayer games
- Custom pub/sub patterns
- High-frequency message passing
For most startups, this level of custom real-time infrastructure is premature. Validate the need first.
Handling reconnections
Any real-time connection should handle disconnections gracefully:
export function useSSEWithReconnect(url: string) {
const source = ref<EventSource | null>(null)
let retryCount = 0
function connect() {
source.value = new EventSource(url)
source.value.onerror = () => {
source.value?.close()
// Exponential backoff: 1s, 2s, 4s, 8s, max 30s
const delay = Math.min(1000 * 2 ** retryCount, 30000)
retryCount++
setTimeout(connect, delay)
}
source.value.onopen = () => {
retryCount = 0 // Reset on successful connection
}
}
onMounted(connect)
onUnmounted(() => source.value?.close())
return source
}
The pragmatic path
For most startup products:
- Start with polling for features where a 30-second delay is acceptable
- Add Supabase Realtime when you need true real-time updates and you're on Supabase
- Use SSE for server-push scenarios without bidirectional needs
- Reach for WebSockets only when you have genuinely custom real-time logic
Don't over-engineer real-time infrastructure early. Get the feature working, validate that users care about the real-time aspect, then invest in the right implementation.