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: 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)
})
Enter fullscreen mode Exit fullscreen mode

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, 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 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. Since setup() also creates a scope for the
component, it will be equivalent to onUnmounted() 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.`)
  }
}
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 — 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()
Enter fullscreen mode Exit fullscreen mode

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
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 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)
Enter fullscreen mode Exit fullscreen mode

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
  }
}
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. 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 }
}
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 (Temporal must be available on the server too — import a polyfill if needed)
  • Clean Temporal formatting (rounded to seconds)
  • shallowRef for immutable Temporal objects (no unnecessary proxying)

Many clocks on one page? 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.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
Enter fullscreen mode Exit fullscreen mode

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

  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)