watch and Scheduler: Async Update Queue, Flush Strategy and Race Condition Cleanup
Chapter 8: watch and the Scheduler — Async Update Queue, Flush Strategies, and Race Condition Cleanup
Vue 3's update queue is a deduplicated microtask queue: 100 state mutations in one macrotask trigger exactly 1 DOM update. This is not accidental — it's the deliberate design of
queueJob+Promise.resolve().
Core Question: Why doesn't the watch callback run the instant state changes? What is nextTick really doing under the hood? How do you safely handle race conditions in async watch?
After Reading This Chapter, You Will Understand:
- The fundamental difference between
watchandwatchEffectand when to use each - How the three core scheduler functions collaborate to implement batched updates
- The behavioral difference between
flush: 'pre'/'post'/'sync'and when to choose each - The implementation of
nextTickand the microtask queue - Using
onCleanup+AbortControllerto correctly handle race conditions
Level 1 · What You Need to Know (1–3 Years Experience)
watch vs watchEffect: Intuitive Comparison
Vue 3 provides two watching APIs with distinct behaviors:
watchEffect: Runs immediately, tracks dependencies automatically
import { ref, watchEffect } from 'vue'
const count = ref(0)
// Runs immediately, tracks count.value
watchEffect(() => {
console.log('count is:', count.value) // Immediately: "count is: 0"
})
count.value = 1 // Async: "count is: 1"
watch: Lazy by default, explicitly specifies the source
import { ref, watch } from 'vue'
const count = ref(0)
// Does NOT run immediately — waits for count to change
watch(count, (newVal, oldVal) => {
console.log(`count: ${oldVal} → ${newVal}`)
})
// Nothing printed yet
count.value = 1 // Async: "count: 0 → 1"
Summary of differences:
| Feature | watchEffect | watch |
|---|---|---|
| First run | Immediately (eager) | Needs immediate: true |
| Dependency tracking | Automatic (runtime collection) | Explicit source |
| Callback params | None | oldValue / newValue |
| Primary use | Syncing side effects | Responding to specific data changes |
| Stop | Returns stop function | Returns stop function |
The Three watch Source Types
watch supports three source types, each with distinct behavior:
const count = ref(0)
const state = reactive({ count: 0, name: 'Vue' })
// 1. Watching a ref (pass directly, without .value)
watch(count, (newVal, oldVal) => {
console.log(newVal, oldVal)
})
// 2. Watching a reactive object (automatically deep)
watch(state, (newState, oldState) => {
// ⚠️ newState === oldState — same Proxy reference!
console.log(newState.count)
})
// 3. Watching a getter function (precise, best performance)
watch(
() => state.count, // Only watches state.count, not state.name
(newVal, oldVal) => {
console.log(`state.count: ${oldVal} → ${newVal}`)
}
)
// Watching multiple sources (array form)
watch([count, () => state.name], ([newCount, newName], [oldCount, oldName]) => {
console.log(newCount, newName)
})
The flush Option: Controlling Callback Timing
The flush option determines when the watch callback executes relative to the component update cycle:
// flush: 'pre' (default)
// Runs before component update — reads pre-update DOM
watch(count, callback, { flush: 'pre' })
// flush: 'post'
// Runs after component update — reads latest DOM
// Equivalent to watchPostEffect
watch(count, callback, { flush: 'post' })
// flush: 'sync'
// Runs synchronously when dependency changes — bypasses scheduler
// ⚠️ Dangerous: may fire multiple times per state mutation
watch(count, callback, { flush: 'sync' })
Practical selection guide:
// Need to access the updated DOM inside the callback? Use flush: 'post'
watch(list, () => {
const el = document.querySelector('.list-item')
// DOM is already updated here
}, { flush: 'post' })
// Need to prepare before DOM updates? Use default 'pre'
watch(isVisible, (val) => {
if (val) {
prepareAnimation() // Component DOM not yet updated
}
})
Correct Usage of nextTick
import { ref, nextTick } from 'vue'
const count = ref(0)
async function increment() {
count.value++
count.value++
count.value++
// DOM not yet updated (batch update still in queue)
console.log(document.getElementById('count')?.textContent) // old value
await nextTick()
// DOM is now updated
console.log(document.getElementById('count')?.textContent) // "3"
}
Level 2 · How It Actually Works (3–5 Years Experience)
The Three Core Scheduler Functions
Vue 3's batched update mechanism is built on three key functions:
1. queueJob(job): Adds an update task to the queue
// Simplified (packages/runtime-core/src/scheduler.ts)
const queue: SchedulerJob[] = []
let isFlushing = false
let isFlushPending = false
export function queueJob(job: SchedulerJob) {
// Dedup check: same job (identified by id) won't be added twice
if (
!queue.length ||
!queue.includes(job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)
) {
if (job.id == null) {
queue.push(job)
} else {
// Insert sorted by id (parent before child)
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush()
}
}
2. queueFlush(): Schedules a microtask to flush the queue
function queueFlush() {
if (!isFlushing && !isFlushPending) {
isFlushPending = true
// Key: schedules a microtask via Promise.resolve()
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
3. flushJobs(): Executes all tasks in the queue
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
// Sort: ensure parent components update before children
queue.sort(comparator)
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
flushIndex = 0
queue.length = 0
// Flush post-flush callbacks (flush: 'post' watch callbacks)
flushPostFlushCbs(seen)
isFlushing = false
currentFlushPromise = null
// If new jobs were added during flush, flush again
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}
Full Flow Diagram: Three Functions Cooperating
User code mutates multiple refs
│
ref.value = 1
ref.value = 2 ────► triggerRefValue(ref)
ref.value = 3 │
▼
Iterate ref.dep effects
│
┌───────┴──────────┐
▼ ▼
render effect watch effect
│ │
▼ ▼
queueJob(renderJob) queueJob(watchJob)
│ │
└──────┬───────────┘
▼
queue = [renderJob, watchJob]
│
▼
queueFlush() (first call)
│
▼
isFlushPending = true
Promise.resolve().then(flushJobs)
│
│ (microtask, after sync code)
▼
flushJobs()
│
┌──────┴──────────┐
▼ ▼
watch pre callback render job
(DOM not yet updated) (update DOM)
│
▼
flushPostFlushCbs()
│
▼
watch post callbacks
(DOM now updated)
nextTick: The Microtask Chain
// packages/runtime-core/src/scheduler.ts
let resolvedPromise: Promise<any> = Promise.resolve() as any
let currentFlushPromise: Promise<void> | null = null
export function nextTick<T = void>(
this: T,
fn?: (this: T) => void,
): Promise<void> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}
Key understanding:
- If there's an active flush (
currentFlushPromiseis not null), nextTick chains after it completes - If there's no active flush, it chains after
resolvedPromise(already resolved) — meaning the callback runs in the next microtask slot
Microtask vs macrotask timing:
Macrotasks: setTimeout, setInterval, I/O, UI rendering
Microtasks: Promise.then, MutationObserver, queueMicrotask
Execution order:
Sync code → all microtasks → UI render → next macrotask
↑
nextTick runs here
Vue 3 deliberately uses microtasks (Promise) rather than macrotasks (setTimeout) so updates happen within the same frame — users never see intermediate states.
Race Condition: The Problem and Solution
Problematic scenario:
// ❌ Race condition risk
const userId = ref(1)
watch(userId, async (newId) => {
const data = await fetchUserData(newId)
// ⚠️ If userId changes during the request,
// a later-issued but faster-returning request
// could overwrite the result of the correct request!
userData.value = data
})
// Timeline:
// t=0: userId=1, fire request A
// t=100ms: userId=2, fire request B
// t=200ms: request B returns (user 2 data is small)
// t=500ms: request A returns (user 1 data is large)
// Result: userData shows user 1's data, but userId is 2!
Correct solution: onCleanup + AbortController:
// ✅ Correct race condition handling
const userId = ref(1)
const userData = ref(null)
const isLoading = ref(false)
watch(userId, async (newId, oldId, onCleanup) => {
const controller = new AbortController()
// onCleanup fires:
// 1. Before the next watch callback (when dep changes)
// 2. When the watch is stopped
onCleanup(() => {
controller.abort() // Cancel the previous in-flight request
})
isLoading.value = true
try {
const response = await fetch(`/api/users/${newId}`, {
signal: controller.signal
})
if (response.ok) {
userData.value = await response.json()
}
} catch (err) {
if (err.name === 'AbortError') {
return // Request was intentionally cancelled — ignore
}
console.error('Request failed:', err)
} finally {
isLoading.value = false
}
})
onCleanup timing diagram:
userId = 1 → callback1 starts → fire request A
│
userId = 2 → callback1's onCleanup fires → abort request A
→ callback2 starts → fire request B
│
userId = 3 → callback2's onCleanup fires → abort request B
→ callback3 starts → fire request C
│
request C returns → userData updated ✓
Level 3 · Design Docs and Source Code (Senior Developers)
queueJob id-based Sorting Strategy
File: packages/runtime-core/src/scheduler.ts
// Sort by job.id ensures parent components update before children
// Child component ids are always larger than parent ids (creation order)
function comparator(a: SchedulerJob, b: SchedulerJob): number {
const diff = getId(a) - getId(b)
if (diff === 0) {
if (a.pre && !b.pre) return -1
if (b.pre && !a.pre) return 1
}
return diff
}
Why this matters: If a child component updated before its parent, its vnode might reference stale parent props. The id-based ordering guarantees parent → child update cascade.
Internal doWatch Implementation
File: packages/runtime-core/src/apiWatch.ts
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{ immediate, deep, flush, once, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ,
): WatchStopHandle {
// 1. Build getter function based on source type
let getter: () => any
if (isRef(source)) {
getter = () => source.value
} else if (isReactive(source)) {
getter = () => source
deep = true // reactive objects are automatically deep
} else if (isArray(source)) {
getter = () => source.map(s => isRef(s) ? s.value : isReactive(s) ? traverse(s) : s())
} else if (isFunction(source)) {
getter = cb
? () => callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
: () => { if (cleanup) cleanup(); return callWithAsyncErrorHandling(source, ...) }
}
// 2. Wrap getter with deep traversal if needed
if (cb && deep) {
const baseGetter = getter
getter = () => traverse(baseGetter())
}
// 3. Build the scheduler job
const job: SchedulerJob = () => {
if (!effect.active || !effect.dirty) return
if (cb) {
const newValue = effect.run()
if (deep || forceTrigger || hasChanged(newValue, oldValue)) {
if (cleanup) cleanup()
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue, oldValue, onCleanup
])
oldValue = newValue
}
} else {
effect.run()
}
}
// 4. Set scheduler based on flush strategy
let scheduler: EffectScheduler
if (flush === 'sync') {
scheduler = job as any
} else if (flush === 'post') {
scheduler = () => queuePostFlushCb(job)
} else {
job.pre = true
if (instance) job.id = instance.uid
scheduler = () => queueJob(job)
}
// 5. Create ReactiveEffect
const effect = new ReactiveEffect(getter, NOOP, scheduler)
// 6. Initial run based on immediate option
if (cb) {
if (immediate) { job() }
else { oldValue = effect.run() } // collect deps only
} else {
effect.run() // watchEffect runs immediately
}
// 7. Return stop handle
return () => {
effect.stop()
if (instance && instance.scope) remove(instance.scope.effects!, effect)
}
}
Component uid and Update Order
// packages/runtime-core/src/component.ts
let uid = 0
export function createComponentInstance(/* ... */) {
const instance: ComponentInternalInstance = {
uid: uid++, // Auto-incrementing; parent < child
// ...
}
}
The render effect's job.id = instance.uid. Since parents are created before children, they get smaller uids and appear first after sorting in flushJobs. This guarantees the parent→child update cascade.
Level 4 · Edge Cases and Traps (All Experience Levels)
Trap 1: oldValue is Invalid When Watching Reactive Objects
const state = reactive({ count: 0, name: 'Vue' })
watch(state, (newVal, oldVal) => {
// ❌ newVal and oldVal are the same Proxy reference
console.log(newVal === oldVal) // true
console.log(oldVal.count) // already the new value!
})
state.count = 1 // fires, but can't get the old value
Root cause: reactive() returns a Proxy. Even if Vue records oldValue, it points to the same object that now reflects the latest state.
Solution:
// ✅ Use getter to watch primitive values
watch(() => state.count, (newVal, oldVal) => {
console.log(newVal, oldVal) // correct before/after values
})
Trap 2: flush: 'sync' Causes Performance Collapse
const items = ref([])
// ❌ Each mutation fires watch synchronously
watch(items, (newItems) => {
processItems(newItems) // assume 5ms per call
}, { flush: 'sync' })
for (let i = 0; i < 1000; i++) {
items.value.push(i) // each push immediately triggers watch
}
// Total: 5ms × 1000 = 5000ms!
// With default flush: 'pre':
// Watch fires once after the loop = 5ms total
Use flush: 'sync' only for unit testing synchronous behavior — never in production hot paths.
Trap 3: watch Created Outside setup() Doesn't Auto-Stop
// ❌ watch created outside component setup won't stop on unmount
export function useGlobalWatch() {
watch(someGlobalRef, callback) // memory leak!
}
// ✅ In setup(), or manually manage lifecycle
export function useFeature() {
const stop = watch(someRef, callback)
onUnmounted(() => stop())
return { stop }
}
Inside setup(), Vue automatically registers the effect to the component's scope, which is cleaned up on unmount.
Trap 4: Async watchEffect Only Tracks Sync Dependencies
// ⚠️ Dependency tracking only works before the first await
watchEffect(async () => {
const id = userId.value // ✓ tracked
const data = await fetchUser(id) // after await...
userData.value = data.result // ✓ write, no tracking needed
console.log(otherRef.value) // ❌ NOT tracked!
// After await, activeEffect has already been restored to null
})
Root cause: ReactiveEffect.run() restores activeEffect to its previous value when the synchronous portion completes. Async functions pause at await, at which point run() has returned and activeEffect is cleared.
Solution: Read all dependencies that need tracking before the first await:
watchEffect(async () => {
// Read all tracked dependencies before any await
const id = userId.value
const filter = filterOptions.value
const data = await fetchUser(id, filter)
userData.value = data.result
})
Chapter Summary
-
watch is lazy, watchEffect is eager:
watchdoesn't run its callback on creation — it only collects dependencies;watchEffectruns immediately and collects. This distinction drives their use cases:watchfor responding to known data sources (with old/new values),watchEffectfor syncing side effects. -
The update queue core is deduplicated microtasks:
queueJobdeduplication +Promise.resolve().then()ensures multiple state mutations within one macrotask trigger exactly one component update. This is foundational to Vue 3's performance. -
flush strategy determines callback timing relative to DOM update:
'pre'runs before DOM update (can't read new DOM),'post'runs after DOM update (can read new DOM),'sync'bypasses the queue (use with extreme caution). -
nextTick is a microtask chain: It chains after the current
flushJobscompletes, or after an already-resolved Promise. Understanding this helps correctly reason about DOM stability afternextTick. -
Race conditions must be handled with onCleanup + AbortController: Relying on stale flags or ordering assumptions in async watch is unreliable.
onCleanupis Vue 3's official cleanup mechanism — combined withAbortController, it fully solves async request race conditions.