Every detective has their cold cases. These are mine — five Vue concepts that
confused me until I investigated them properly. Grab your magnifying glass.
Case #1: The Lying Clock
Imagine you set an alarm to go off every 60 seconds. You press start at
10:00:00.
- First alarm: 10:01:00 — perfect
- Second alarm: 10:02:00 — still good
- But your phone is also doing other things: checking email, refreshing weather, running background tasks. Sometimes it fires the alarm a tiny bit late.
- Third alarm: 10:03:01 (1 second late)
- Fourth alarm: 10:04:02 (2 seconds off now)
- Five hours later: your alarm fires at 15:05:12 when it should fire at 15:05:00
That gap growing bigger over time — that’s drift. The timer slowly slides
away from where it should be.
Why Does This Happen?
JavaScript runs on a single thread — it can only do one thing at a time. When a
timer is supposed to fire, the browser puts it in a queue. But if the thread is
busy doing something else, the timer waits. The MDN documentation for
setTimeout
lists several reasons timers fire late:
- Nested timeouts are throttled to a minimum of 4ms after 5 levels of nesting (per the HTML5 spec)
- Background tabs are throttled to a maximum of once per second (MDN: “timeouts are throttled to firing no more often than once per second (1000 ms) in inactive tabs”)
- Chrome 88+ introduced intensive throttling for hidden pages: timers that have been hidden for more than 5 minutes are checked only once per minute
- Tracking scripts in Firefox get even more aggressive throttling: 10 second minimum in background tabs
Does This Happen on New Devices Too?
Yes, but less. Modern devices are faster, so the delay per tick is smaller —
maybe 1-2 milliseconds instead of 10-20. But over hours, even 1ms per tick
adds up. And background tab throttling happens on every device, no matter how
fast — it’s a browser policy, not a hardware limitation.
Is This Common Knowledge?
It’s the kind of thing you learn when your boss says “why does the clock on
our dashboard show the wrong time after lunch?” and you Google it. Many
developers use setInterval and never notice because their app doesn’t stay
open long enough for the drift to matter.
Case #1 Continued: How Recursive setTimeout Fixes the Drift
A fixed interval can fall behind its intended clock boundary when callbacks
run late — the lateness piles up:
Start at 10:00:00
→ fire at 10:01:00.002 (2ms late)
→ fire at 10:02:00.005 (5ms late)
→ fire at 10:03:00.009 (getting worse)
→ ...hours later, you're 10+ seconds off
Enter fullscreen mode Exit fullscreen mode
Recursive setTimeout recalculates the next boundary from wall-clock time
each tick, so delay doesn’t accumulate:
Start at 10:00:00
→ "next minute is 10:01:00, that's 60000ms away" → set timeout for 60000ms
→ fire at 10:01:00.002 (2ms late — same as before)
→ BUT NOW: check clock: "it's 10:01:00.002, next minute is 10:02:00, that's 59998ms"
→ set timeout for 59998ms
→ fire at 10:02:00.001 (only 1ms late! it corrected itself!)
→ check clock: "it's 10:02:00.001, next minute is 10:03:00, that's 59999ms"
→ fire at 10:03:00.000 — right on time
Enter fullscreen mode Exit fullscreen mode
It’s called “recursive” because the function calls itself — inside the
setTimeout callback, we call scheduleNextTick() again, which sets up the
next setTimeout. It’s a loop that builds itself one step at a time.
Analogy:
-
setInterval= a runner who counts their steps and hopes they’re on pace - Recursive
setTimeout= a runner who checks their watch before every step
Case #2: The Box That Didn’t Need Tracking — shallowRef vs ref
When you put an object inside ref(), Vue wraps every single property in a
reactive proxy. If your object has { hour: 14, minute: 30, second: 15 }, Vue
tracks all three properties — plus every method, plus any nested objects. This
is great when you need to change one property and have the UI update.
But Temporal.PlainTime is immutable — you can never change its properties.
You can only throw the whole thing away and make a new one. So Vue’s deep
tracking is wasted effort.
shallowRef says: “only track .value itself, don’t go inside.” The Vue
documentation puts
it plainly:
Unlike
ref(), the inner value of a shallow ref is stored and exposed as-is,
and will not be made deeply reactive. Only the.valueaccess is reactive.
And the docs even tell you when to use it:
shallowRef()is typically used for performance optimizations of large data
structures, or integration with external state management systems.
Since we only ever do now.value = getNow() (replace the whole object) and
never mutate internal properties, shallowRef does exactly what we need — and
nothing we don’t.
Analogy: ref is like putting a GPS tracker on every item inside a box.
shallowRef is like putting a tracker only on the box. If you never open the
box and change items inside — you only swap the whole box — the single tracker
is enough.
Case #3: The Cleanup That Worked Everywhere — Effect Scopes and onScopeDispose
The Problem
When a component unmounts, Vue needs to clean up — stop timers, remove event
listeners, cancel watchers. onUnmounted is the hook for this. But it only
works inside components. If you use a composable from a Pinia store or a
standalone module, onUnmounted never fires — there’s no component to unmount.
The Solution
Vue 3.2 introduced effectScope and onScopeDispose via
RFC #0041.
The RFC explains the motivation:
In Vue’s component
setup(), effects will be collected and bound to the
current instance. When the instance gets unmounted, effects will be disposed
automatically. This RFC is trying to abstract the component’ssetup()
effect collecting and disposing feature into a more reusable API that can be
used outside of the component model.
In plain English: Vue already had this cleanup mechanism inside components. They
just made it available outside components too.
How It Works
Every component’s setup() runs inside an effect scope — Vue creates it
automatically. onScopeDispose registers a cleanup callback on whatever scope
is currently active. The Vue docs
describe it as:
Registers a dispose callback on the current active effect scope. This method
can be used as a non-component-coupled replacement ofonUnmountedin
reusable composition functions, since each Vue component’ssetup()function
is also invoked in an effect scope.
So inside a component, onScopeDispose and onUnmounted do the same thing.
But onScopeDispose also works in Pinia stores and standalone scopes.
Will onMounted and onUnmounted Go Away?
-
onMounted— stays forever. It’s about the DOM being ready, not about cleanup. NeithereffectScopenoronScopeDisposecan replace it. -
onUnmounted— mostly replaced byonScopeDisposefor cleanup in composables. It still works, butonScopeDisposeis more versatile.
What Happens If You Use Both Inside a Component?
Nothing bad — they both fire during teardown. No conflict, no error.
It’s just redundant — using both for the same cleanup is unnecessary.
The Catch: No Active Scope
If onScopeDispose is called with no active scope (like from a plain module),
Vue logs a warning and the callback is never registered. The
Vue source
shows exactly why:
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...`)
}
}
Enter fullscreen mode Exit fullscreen mode
No activeEffectScope → no push → no cleanup. That’s why our composable uses
failSilently = true (to suppress the warning) and exposes a stop()
function (so the caller can clean up manually).
Analogy: onUnmounted is like “tell me when the building is demolished.”
onScopeDispose is like “tell me when my room is demolished” — works whether
your room is in a building, a store, or a standalone structure. But if you’re
not in any room at all, nobody will tell you anything — so keep a fire
extinguisher (stop()) handy.
Case #4: The Server That Knew Too Much — Hydration
The Short Version
When using SSR (Server-Side Rendering, like Nuxt), the server generates complete
HTML and sends it to the browser. The user sees a full page immediately. Then
Vue loads in the browser and “takes over” that HTML — attaching event listeners,
making it interactive. That takeover is hydration.
Why It Matters for Clocks
The server renders at one moment — say 10:00:00. That HTML travels across the
internet for 500ms. The browser hydrates at 10:00:01. Vue compares the server
HTML to what it wants to render and finds a mismatch: the server wrote 10:00:00
but the browser wants 10:00:01.
The Nuxt hydration guide
lists this as a common problem:
Content that changes based on current time.
And Nazar Boyko’s deep dive on hydration
explains why it matters:
The server runs this code at, say, 14:32:01. The client runs it at 14:32:03.
The text nodes do not match, and Vue complains.
It gets worse: the server might be in a different time zone than the user. A
server in New York rendering 10:00:00 EST is 15:00:00 for a user in Tokyo.
The Fix
The cleanest pattern is to render a placeholder on the server and start the real
clock only after the browser takes over. As the
Mastering Nuxt guide
recommends:
Render time only on the client.
Or use Nuxt’s <ClientOnly>
component, or the
<NuxtTime> component which is
specifically designed for SSR-safe time rendering.
Analogy: It’s like a restaurant menu printed yesterday with “Today’s Special:
Soup.” When you read it today, it’s wrong. Better to leave the special blank on
the printed menu and write it in by hand when the customer sits down.
Does This Apply to Every Project?
No — if you’re using plain Vite + Vue, not SSR, there’s no hydration. But a
good composable handles it anyway with the typeof window !== 'undefined'
guard, which is good practice for reusable code that someone might use in Nuxt
someday.
Case #5: The Warning Nobody Needed — failSilently
When onScopeDispose is called with no active scope, Vue logs a warning:
“Hey, there’s no scope to attach this to!” That warning can be noisy if you
intentionally call the composable outside a scope.
Passing true as the second argument tells Vue: “I know there might not be a
scope. Don’t warn me. I’ll handle cleanup myself with stop().”
Important: failSilently = true does not create cleanup. It just hides
the warning. The callback is still not registered. You still need stop().
Mini-note: Temporal Rounding —
Temporal.Now.plainTimeISO()gives you
nanosecond precision (14:30:15.123456789). That’s nine digits after the
seconds..round({ smallestUnit: 'second' })trims it to14:30:15. Clean.
Like showing “$14.30” instead of “$14.300000001.”
Case Closed
-
Timer drift — A fixed interval can fall behind its intended clock boundary; recursive
setTimeoutrecalculates from wall-clock time each tick -
shallowRef— Only tracks.valuereplacement, not internal property changes — perfect for immutable objects -
Effect scopes +
onScopeDispose— Scopes group reactive effects for disposal;onScopeDisposeregisters cleanup on the active scope — works in components, setup-store creation, and manual scopes, but not with no scope at all - Hydration — Server sends HTML, browser “takes over” — time-based content can mismatch between the two
-
failSilently— Suppresses the “no active scope” warning; does NOT create cleanup — you still needstop()

답글 남기기
댓글을 달기 위해서는 로그인해야합니다.