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:
-
onUnmountedonly works inside components. If someone calls this logic from a Pinia store or outside a component’ssetup()context,onUnmountednever fires. The timer leaks silently. (Composables called synchronously duringsetup()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
onUnmountedin 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)
-
shallowReffor 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
“Works” is not “works well.” The naive
setIntervalticks. But it leaks, drifts from real time, and wastes CPU on hidden tabs.Prefer
onScopeDisposefor reusable composables. It works inside any active Vue effect scope (components, Pinia setup stores,effectScope()) — and inside components it’s literally equivalent toonUnmounted, per RFC #0041. But it’s not magic: if there’s no active scope, the callback is never registered. Always expose a manualstop()for unmanaged usage.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.
Self-correcting beats fixed intervals.
setIntervaldrifts under load. A recursivesetTimeoutthat recalculates alignment on every tick ensures delay doesn’t accumulate — the next tick always targets the next real clock boundary.Guard against SSR. If your composable touches
documentorwindow, wrap it intypeof window !== 'undefined'. And if you use Temporal, make sure the server has it too — natively or via polyfill.Use
shallowReffor immutable objects.Temporal.PlainTimeis
immutable — you only ever replace.value, never mutate its internals.shallowRefavoids 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. ⚖️