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.

Syntax<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

StateWhat to show
Connected, data recentA quiet live marker, nothing more
ReconnectingThe last data, marked stale, with the age
Given upThe 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

Example · vue
<!-- 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.

Example · ts
// 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 data

Not 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.

Example · ts
// 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.

Example · vue
<!-- 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

  • Be the first to comment on this lesson.