Making Live Data Trustworthy
Show the connection state and the age of the data, keep the last values on screen while reconnecting, and offer one retry.
<connection-banner :status="status" :last-updated-at="lastUpdatedAt" @retry="reconnect" />Live data fails quietly. A dropped socket leaves the last numbers on screen looking current, and the reader keeps using them. Three things make a live view trustworthy: the connection state, the age of the data, and one action the user can take.
Three states, not a boolean
| State | What to show |
|---|---|
| Connected, data recent | A quiet live marker, nothing more |
| Reconnecting | The last data, marked stale, with the age |
| Given up | The last data, the age, and a retry button |
Keep the data on screen in all three. Blanking a table because of a two-second blip is worse than showing values a few seconds old and saying how old they are.
Age needs its own clock
Vue's reactivity reacts to changes it can observe. A stored timestamp does not change as time passes, so a label computed from it alone freezes at the value it had when the component rendered. Keep a now ref, advance it with setInterval, derive the age with computed, and clear the interval in onUnmounted or the timer outlives the component.
Details that matter
Delay the reconnecting message by about a second, so a short blip does not flash a banner. Put role="status" on the element, which asks assistive technology to announce a change in it without moving focus. One retry button is enough; more than one path back is confusing. In templates @click is the shorthand for v-on:click and :status for v-bind:status, the same directive in both forms.
Example
<!-- src/components/ConnectionBanner.vue -->
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
const props = defineProps<{
status: 'connecting' | 'open' | 'closed'
lastUpdatedAt: number | null
staleAfterMs?: number
}>()
const emit = defineEmits<{ retry: [] }>()
const now = ref(Date.now())
const ticker = setInterval(() => {
now.value = Date.now()
}, 1000)
onUnmounted(() => clearInterval(ticker))
const ageSeconds = computed(() =>
props.lastUpdatedAt === null
? null
: Math.floor((now.value - props.lastUpdatedAt) / 1000)
)
const isStale = computed(() => {
const age = ageSeconds.value
return age !== null && age * 1000 > (props.staleAfterMs ?? 15000)
})
</script>
<template>
<p class="banner" role="status">
<span v-if="status === 'closed'">Disconnected</span>
<span v-else-if="status === 'connecting'">Reconnecting</span>
<span v-else-if="isStale">No update for a while</span>
<span v-else>Live</span>
<span v-if="ageSeconds !== null"> · updated {{ ageSeconds }}s ago</span>
<button v-if="status === 'closed'" type="button" @click="emit('retry')">
Try again
</button>
</p>
</template>
<style scoped>
.banner {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
</style>When to use it
- Tell a dashboard user that the numbers they are reading stopped updating two minutes ago.
- Mark a live table stale and keep it visible while the connection retries.
- Give someone whose connection has given up a single retry button that works.
- Stop a banner flickering on and off during a flaky mobile connection.
More examples
The wording as a pure function
Once the banner has four or five wordings, the decision is worth separating from the markup: a plain function takes the status and the age and returns the string, and a test can pin every branch without a component or a fake clock. It also stops the two sources of truth drifting apart, because an open connection that has sent nothing for a minute is not the same situation as a closed one, and the wording has to say which it is.
// src/lib/connectionLabel.ts
export type SocketStatus = 'connecting' | 'open' | 'closed'
export function connectionLabel(status: SocketStatus, ageMs: number | null) {
if (status === 'closed') return 'Disconnected'
if (status === 'connecting') return 'Reconnecting'
if (ageMs === null) return 'Waiting for data'
return ageMs > 15000 ? 'No update for a while' : 'Live'
}
console.log(connectionLabel('open', 2000))
// Output: Live
console.log(connectionLabel('open', 40000))
// Output: No update for a while
console.log(connectionLabel('connecting', 2000))
// Output: Reconnecting
console.log(connectionLabel('open', null))
// Output: Waiting for dataNot flashing the banner on a short blip
Turning true is delayed by a second; turning false is immediate. A socket that drops and reconnects in 300ms never sets the flag, so the user sees nothing, while a real outage shows the message a second in. toValue (Vue 3.3 and later) accepts a plain boolean, a ref or a getter, so the caller can pass () => status.value === 'connecting' without wrapping it first. Wire it as const reconnecting = useDelayed(() => status.value === 'connecting') and bind that to the banner instead of the raw status.
// src/composables/useDelayed.ts
import { onScopeDispose, readonly, ref, toValue, watch } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
export function useDelayed(source: MaybeRefOrGetter<boolean>, ms = 1000) {
const delayed = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
watch(
() => toValue(source),
(value) => {
clearTimeout(timer)
if (value) {
timer = setTimeout(() => {
delayed.value = true
}, ms)
} else {
delayed.value = false
}
},
{ immediate: true }
)
onScopeDispose(() => clearTimeout(timer), true)
return readonly(delayed)
}Wiring it to a live feed
The view stamps lastUpdatedAt every time a message lands, which is the only reliable measure of data age: the connection can be open and silent. The banner is told the status and that timestamp and emits retry, which the view answers with the composable's reconnect, so the button does the work and the banner stays reusable. The last reading stays rendered while the socket retries, which is the point: never clear data you still have, label it instead.
<!-- src/views/DashboardView.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import ConnectionBanner from '@/components/ConnectionBanner.vue'
import { useSocket } from '@/composables/useSocket'
const { status, lastMessage, reconnect } = useSocket('wss://stream.example.com/metrics')
const lastUpdatedAt = ref<number | null>(null)
const reading = ref<string | null>(null)
watch(lastMessage, (value) => {
if (value === null) return
reading.value = value
lastUpdatedAt.value = Date.now()
})
</script>
<template>
<main class="dashboard">
<connection-banner
:status="status"
:last-updated-at="lastUpdatedAt"
@retry="reconnect"
/>
<p v-if="reading">{{ reading }}</p>
<p v-else>No reading yet</p>
</main>
</template>
<style scoped>
.dashboard {
display: grid;
gap: 1rem;
}
</style>
Discussion