DEV Community

Cover image for Perry Mason in: The Case of the Drifting Timer
Izak T
Izak T

Posted on

Perry Mason in: The Case of the Drifting Timer

Perry Mason in: The Case of the Drifting Timer

Opening Statement

You need a reactive "current time" in your Vue 3 app. A schedule grid with a red line showing "now." A live clock. A dashboard that updates every minute.

Every Vue developer reaches for setInterval first. It works. But "works" and "works well" are different things.

This is the story of taking a naive timer from "it ticks" to production-grade — and the four iterations it took to get there. The prosecution calls four exhibits. Let's begin.

Exhibit A: The Memory Leak

const currentTime = ref(new Date())

onMounted(() => {
  setInterval(() => {
    currentTime.value = new Date()
  }, 60000)
})
Enter fullscreen mode Exit fullscreen mode

It works. Sort of. The defense rests — but the prosecution is just getting started.

Exhibits of negligence:

  • The interval is never cleared. When the component unmounts, the timer keeps firing every 60 seconds forever — updating a ref nothing reads anymore, and holding its closure (and everything the ref references) in memory for the lifetime of the page.
  • Silent. Invisible. The kind of leak that shows up in production after a user navigates around your app for 20 minutes.

Exhibit B: Component-Only Cleanup

const currentTime = ref(new Date())
let timeInterval = null

onMounted(() => {
  currentTime.value = new Date()
  timeInterval = setInterval(() => {
    currentTime.value = new Date()
  }, 60000)
})

onUnmounted(() => {
  if (timeInterval) clearInterval(timeInterval)
})
Enter fullscreen mode Exit fullscreen mode

Now we clean up. The interval is stored in a variable, cleared on unmount. A step forward. But onUnmounted has a scope limitation worth understanding:

The limitation:

  • onUnmounted only works inside components. If someone calls this logic from a Pinia store or outside a component's setup() context, onUnmounted never fires. The timer leaks silently. (Composables called synchronously during setup() are fine — Vue's docs recommend exactly that. The problem is when there's no component instance at all.)
  • The timer fires 60 seconds after load, not at the top of the minute. If a user opens your app at 10:00:58, the clock won't update until 10:01:58. Your app looks broken — the system tray says 10:01 but your UI still shows 10:00.
  • The timer keeps running when the tab is hidden. Wasting CPU on a clock nobody is looking at.

Exhibit C: Scope-Safe Cleanup

Vue has a method for scope-safe cleanup: onScopeDispose. From the documentation:

This method can be used as a non-component-coupled replacement of
onUnmounted in reusable composition functions, since each Vue component's
setup() function is also invoked in an effect scope.

The implementation is just a push onto the currently active scope's cleanup list. From the Vue core source (roughly — packages/reactivity/src/effectScope.ts):

function onScopeDispose(fn, failSilently = false) {
  if (activeEffectScope) {
    activeEffectScope.cleanups.push(fn)
  } else if (__DEV__ && !failSilently) {
    warn(`onScopeDispose() is called when there is no active effect scope...`)
  }
}
Enter fullscreen mode Exit fullscreen mode

"Currently active" is the key phrase. Every component's setup() runs inside an effect scope that Vue creates and activates for you, so inside a component the cleanup is automatic.

onScopeDispose() cleans up automatically when useNow() is called synchronously within an active Vue effect scope — such as component setup or setup-store creation. Outside one, callers must retain and call the returned stop() or create their own scope.

// In a component — Vue creates the scope, onScopeDispose just works
const { now: currentTime } = useNow()
Enter fullscreen mode Exit fullscreen mode

Pinia setup stores also run inside their own effect scope. Pinia's $dispose() method stops the store's associated effect scope (its source calls scope.stop()), which invokes any onScopeDispose callbacks registered during setup. Note that unlike component unmount, $dispose() must be called intentionally — it is not automatic.

The only time you reach for effectScope() yourself is when there's genuinely no scope to hook into — app-level singletons, plugins, plain modules. In that case, either create a scope and remember to stop it:

// No component, no store — create a scope, and remember to stop it
const scope = effectScope()
scope.run(() => {
  const { now: currentTime } = useNow()
})
scope.stop() // triggers cleanup
Enter fullscreen mode Exit fullscreen mode

…or skip the scope and call stop() manually:

// No scope at all — caller disposes manually
const { now: currentTime, stop } = useNow()
// ...later, when the owner is torn down:
stop()
Enter fullscreen mode Exit fullscreen mode

One caveat, straight from the Vue docs: if onScopeDispose is called with no active scope, Vue logs a warning and the callback is never registered. Vue 3.5+ accepts a second argument, onScopeDispose(cleanup, true), which suppresses the warning — but the callback is still not registered. That's why the final composable uses failSilently = true and exposes stop() for manual disposal.

Exhibit D: Clock Alignment + Visibility Pause

The final composable (shown at the end of this article) addresses the human-perception problem and the battery-drain problem.

Clock Alignment

If you load at 10:00:58, you don't want to wait 60 seconds for the first tick. You want the clock to update at 10:01:00 — aligned to the next interval boundary (minute boundaries with the default 60,000 ms interval).

The composable reads the current time immediately on start, then schedules every subsequent tick at the next interval boundary. So the clock is correct from the moment it loads, and stays aligned on every tick.

const msUntilNextTick = interval - (Date.now() % interval)
setTimeout(() => {
  now.value = Temporal.Now.plainTimeISO()
  timer = setInterval(() => {
    now.value = Temporal.Now.plainTimeISO()
  }, interval)
}, msUntilNextTick)
Enter fullscreen mode Exit fullscreen mode

With the default 60,000 ms interval, msUntilNextTick is the milliseconds remaining until the next minute boundary. The initial setTimeout fires at that boundary, then hands off to a recurring timer for all subsequent ticks. The clock feels synchronized with the system tray.

But there's a subtlety: setInterval drifts. Modern browsers under system load or low-power modes can slip a few milliseconds per tick. Over hours, the timer ends up firing 5–10 seconds past the top of the minute. The fix is a self-correcting recursive setTimeout that recalculates interval - (Date.now() % interval) on every tick — re-aligning to the real interval boundary each time. Delay does not accumulate from one tick to the next — if the browser runs a callback late, the next callback is scheduled toward the next clock boundary rather than inheriting the previous lateness.

Visibility Pause

When the user switches tabs, document.hidden becomes true. We stop the timer. When they come back, we immediately sync the time and restart the interval.

const onVisibilityChange = () => {
  if (document.hidden) {
    stop()
  } else {
    start() // immediately reads the current time, then starts ticking
  }
}
Enter fullscreen mode Exit fullscreen mode

No wasted CPU when nobody is looking. No stale time when they return.

SSR Safety

If this composable runs during Server-Side Rendering (Nuxt, Vite SSG), document doesn't exist. Calling document.addEventListener at the top level would crash with ReferenceError: document is not defined.

The fix: wrap all DOM-dependent code in a typeof window !== 'undefined' guard. The DOM code is SSR-safe. Separately, if you use Temporal, the server needs it too — natively or through a polyfill imported at your app's entry point. These are two independent requirements: no DOM on the server, and Temporal availability on the server.

Hydration note: Rendering live time on the server can cause hydration
mismatches because the server and browser render at different moments — and
potentially in different time zones. The cleanest pattern for a live clock is
to render a neutral placeholder server-side and start the actual clock after
client mount.

Temporal Precision

Temporal.Now.plainTimeISO() defaults to nanosecond precision (e.g., 14:30:15.123456789). When bound directly to a template, those sub-second digits render as noise. The fix: round to seconds with .round({ smallestUnit: 'second' }) so now.value formats cleanly out of the box.

The Verdict: The Final Composable

import { shallowRef, onScopeDispose } from 'vue'

const isBrowser = typeof window !== 'undefined'

export function useNow(interval = 60000) {
  if (!Number.isFinite(interval) || interval <= 0) {
    throw new RangeError('interval must be a positive finite number of milliseconds')
  }

  // Round to seconds so default template rendering is clean (e.g., "14:30:15")
  const getNow = () =>
    Temporal.Now.plainTimeISO().round({ smallestUnit: 'second' })

  // shallowRef: Temporal.PlainTime is immutable — no need for Vue to proxy
  // its internals. We only ever replace .value, never mutate it.
  const now = shallowRef(getNow())
  let timer = null

  const stop = () => {
    if (timer !== null) {
      clearTimeout(timer)
      timer = null
    }
  }

  // Self-correcting recursive timeout: recalculates remaining ms to next
  // boundary on every tick — delay does not accumulate across ticks
  const scheduleNextUpdate = () => {
    stop()
    const msUntilNextTick = interval - (Date.now() % interval)
    timer = setTimeout(() => {
      now.value = getNow()
      scheduleNextUpdate()
    }, msUntilNextTick)
  }

  const start = () => {
    // Don't schedule if the page is already hidden (e.g. loaded in a
    // background tab) — visibilitychange only fires on state changes
    if (isBrowser && document.hidden) return
    stop()
    now.value = getNow()
    scheduleNextUpdate()
  }

  const onVisibilityChange = () => {
    if (document.hidden) stop()
    else start()
  }

  const dispose = () => {
    stop()
    if (isBrowser) {
      document.removeEventListener('visibilitychange', onVisibilityChange)
    }
  }

  // SSR Safe: only attach DOM listeners in the browser environment
  if (isBrowser) {
    start()
    document.addEventListener('visibilitychange', onVisibilityChange)
  }

  // failSilently=true: if no active scope, caller must call stop() manually
  onScopeDispose(dispose, true)

  return { now, stop: dispose }
}
Enter fullscreen mode Exit fullscreen mode

Usage in a component:

// Vue creates the scope — onScopeDispose handles cleanup automatically
const { now: currentTime } = useNow()
Enter fullscreen mode Exit fullscreen mode

Usage outside a scope (plugins, plain modules):

// No active scope — caller disposes manually
const { now: currentTime, stop } = useNow()
// ...later, when the owner is torn down:
stop()
Enter fullscreen mode Exit fullscreen mode

The composable handles:

  • Clock alignment to interval boundaries (minute boundaries with the default 60,000 ms interval; self-correcting, non-accumulating delay)
  • Tab visibility pausing (including when the page loads already hidden)
  • Scope-safe cleanup when called inside an active scope (components, Pinia setup stores, effectScope)
  • Manual disposal via stop() when called outside a scope
  • SSR safety for DOM code (if you use Temporal, the server needs it too — import a polyfill at your app's entry point if needed)
  • Clean Temporal formatting (rounded to seconds)
  • shallowRef for immutable Temporal objects (no unnecessary proxying)

Many Clocks: Share One Instance

Each useNow() call creates its own timer and visibilitychange listener. That's fine for one or a few clocks, but if a page renders many clock components, consider sharing a single app-level useNow() instance via effectScope() and passing the ref down via props or provide/inject.

Closing Arguments: Why Recursive setTimeout Instead of setInterval?

setInterval: Fires on a fixed interval, but modern browsers drift under load or in low-power modes. A few milliseconds per tick compounds over hours — your clock ends up 5–10 seconds off the real interval boundary. Since we're building a clock UI, that drift is visible to users.

setTimeout (recursive): Each callback recalculates interval - (Date.now() % interval) before scheduling the next tick. This re-aligns to the real interval boundary on every tick. Delay does not accumulate — if the browser runs a callback late, the next callback targets the next clock boundary rather than inheriting the previous lateness. The callback reads Temporal.Now.plainTimeISO() — instantaneous — so there's no risk of overlapping calls.

requestAnimationFrame: Fires ~60x/sec, synced to screen refresh. Designed for smooth animations. Way too frequent for a 60-second clock.

nextTick: Not a timer at all. Fires once, after Vue's DOM update cycle. No concept of time or repetition.

For a clock that needs to stay in sync with the system tray over long sessions, self-correcting recursive setTimeout is the right tool. Browser timer scheduling is not exact — the event loop can be busy, and background tabs are commonly throttled — but self-correction ensures that lateness doesn't compound.

Expert Testimony: Why Temporal API Instead of Date?

Date is perfectly reasonable for a simple display clock — especially if your app doesn't already depend on Temporal. But if your app already uses Temporal for its date/time semantics, it's the natural choice here.

The Date object is mutable — setHours(), setDate(), setMinutes() all modify the instance in place. This leads to subtle bugs when the same instance is shared across reactive state.

Temporal.PlainTime and Temporal.PlainDate are immutable. Every operation returns a new instance. No mutation bugs possible. PlainTime represents wall-clock time without a date or time zone — exactly what a display clock needs.

// Date — mutable, error-prone
const d = new Date()
d.setDate(d.getDate() + 1) // tomorrow, but d is mutated in place

// Temporal — immutable, safe
const today = Temporal.Now.plainDateISO()
const tomorrow = today.add({ days: 1 }) // new instance, today unchanged
Enter fullscreen mode Exit fullscreen mode

As of mid-2026 it's formally standardized as Stage 4 and being merged into the ES2027 specification (the TC39 finished-proposals list updated the target publication year from 2026 to 2027). It's available in current Chromium and Firefox, but full cross-browser support still requires a fallback or polyfill — Safari and iOS Safari remain unsupported. The proposal's implementation status and caniuse agree:

  • Chrome 144+ — shipped January 13, 2026
  • Firefox 139+ — shipped May 27, 2025
  • Edge 144+ — Chromium-aligned
  • Safari — Technology Preview only, disabled by default; the WebKit implementation bug is still open
  • Node.js 26+ — shipped May 5, 2026

If you need to support browsers without native Temporal, import a Temporal polyfill at your app's entry point. Two options are @js-temporal/polyfill (closest to the reference implementation, currently alpha) or temporal-polyfill (by FullCalendar, stable release). Pick the one that fits your needs.

Case Summary

  1. "Works" is not "works well." The naive setInterval ticks. But it leaks, drifts from real time, and wastes CPU on hidden tabs.

  2. Prefer onScopeDispose for reusable composables. It works inside any active Vue effect scope (components, Pinia setup stores, effectScope()) — and inside components it's literally equivalent to onUnmounted, per RFC #0041. But it's not magic: if there's no active scope, the callback is never registered. Always expose a manual stop() for unmanaged usage.

  3. Clock alignment is a human-perception problem. Users don't know what "drift" means, but they know your clock looks wrong when it doesn't match their system tray.

  4. Self-correcting beats fixed intervals. setInterval drifts under load. A recursive setTimeout that recalculates alignment on every tick ensures delay doesn't accumulate — the next tick always targets the next real clock boundary.

  5. Guard against SSR. If your composable touches document or window, wrap it in typeof window !== 'undefined'. And if you use Temporal, make sure the server has it too — natively or via polyfill.

  6. Use shallowRef for immutable objects. Temporal.PlainTime is
    immutable — you only ever replace .value, never mutate its internals. shallowRef avoids unnecessary proxy conversion and is the appropriate model for external class instances.

The court finds setInterval guilty of negligence. The composable is released into production. Case closed. ⚖️

Top comments (0)