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)
})
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: The Cleanup That Failed
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)
})
Now we clean up. The interval is stored in a variable, cleared on unmount.
A step forward — but the prosecution has three more objections:
Further evidence:
- This only works inside components. If someone calls this logic from a Pinia
store or outside a component's
setup()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 3.2 introduced onScopeDispose — proposed in
RFC #0041: Reactivity Effect Scope
and shipped in Vue 3.2 (August 2021).
The RFC describes it as:
The global hook
onScopeDispose()serves a similar functionality to
onUnmounted(), but works for the current scope instead of the component
instance. This could benefit composable functions to clean up their side
effects along with its scope. Sincesetup()also creates a scope for the
component, it will be equivalent toonUnmounted()when there is no explicit
effect scope created.— Vue RFC #0041, "Reactivity Effect Scope"
That's not marketing — the implementation really is just a push onto the
currently active scope's cleanup list. From the Vue core source
(packages/reactivity/src/effectScope.ts):
function onScopeDispose(fn, failSilently = false) {
if (activeEffectScope) {
activeEffectScope.cleanups.push(fn)
} else if (!failSilently) {
warn(`onScopeDispose() is called when there is no active effect scope to be associated with.`)
}
}
"Currently active" is the key phrase. Every component's setup() runs inside an
effect scope that Vue creates and activates for you — before invoking your setup
code, setupComponent routes through setCurrentInstance, which calls
instance.scope.on() — so inside a component the cleanup is automatic. The RFC's
own example makes the guarantee explicit:
This still works because a Vue component now also runs its
setup()inside a
scope, which will be disposed when the component is unmounted.— Vue RFC #0041, "Reactivity Effect Scope"
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()
Pinia setup stores also run inside their own effect scope. The
Pinia $dispose() docs
confirm: calling store.$dispose() stops the store's associated effect scope,
which triggers 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
…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()
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 version 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).
const msUntilNextTick = interval - (Date.now() % interval)
setTimeout(() => {
now.value = Temporal.Now.plainTimeISO()
timer = setInterval(() => {
now.value = Temporal.Now.plainTimeISO()
}, interval)
}, msUntilNextTick)
The initial setTimeout fires at the next interval 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
}
}
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. To make the composable fully SSR-safe, the
server and target browsers must also provide Temporal, either natively or
through an explicitly imported polyfill.
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'
// If Temporal isn't available natively (e.g. Safari), import a polyfill first:
// import { Temporal } from '@js-temporal/polyfill'
export function useNow(interval = 60000) {
if (!Number.isFinite(interval) || interval <= 0) {
throw new RangeError('interval must be a positive 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 scheduleNextTick = () => {
stop()
const msUntilNextTick = interval - (Date.now() % interval)
timer = setTimeout(() => {
now.value = getNow()
scheduleNextTick()
}, 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 (typeof document !== 'undefined' && document.hidden) return
stop()
now.value = getNow()
scheduleNextTick()
}
const onVisibilityChange = () => {
if (document.hidden) stop()
else start()
}
const dispose = () => {
stop()
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}
// SSR Safe: only attach DOM listeners in the browser environment
if (typeof window !== 'undefined') {
start()
document.addEventListener('visibilitychange', onVisibilityChange)
}
// failSilently=true: if no active scope, caller must call stop() manually
onScopeDispose(dispose, true)
return { now, stop: dispose }
}
Usage in a component:
// Vue creates the scope — onScopeDispose handles cleanup automatically
const { now: currentTime } = useNow()
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()
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 (Temporal must be available on the server too — import a polyfill if needed)
- Clean Temporal formatting (rounded to seconds)
-
shallowReffor immutable Temporal objects (no unnecessary proxying)
Many clocks on one page? Each
useNow()call creates its own timer and
visibilitychangelistener. 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 viaeffectScope()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.setHours(0, 0, 0, 0) // mutates d in place
// Temporal — immutable, safe
const today = Temporal.Now.plainDateISO() // no time component to zero out
const tomorrow = today.add({ days: 1 }) // returns new instance, today unchanged
Temporal's status, per the
official TC39 proposal:
This proposal is currently Stage 4. It will be merged into the ECMA-262 and
ECMA-402 standards and this repository will be archived.— tc39/proposal-temporal
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 production 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, drop in a polyfill such
as @js-temporal/polyfill
or temporal-polyfill.
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 RFC0041. But it's not magic: if there's no active scope, the callback is never
registered. Always expose a manual
stop()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. ⚖️
Top comments (0)