Chapter 8

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:


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:

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

  1. watch is lazy, watchEffect is eager: watch doesn't run its callback on creation — it only collects dependencies; watchEffect runs immediately and collects. This distinction drives their use cases: watch for responding to known data sources (with old/new values), watchEffect for syncing side effects.

  2. The update queue core is deduplicated microtasks: queueJob deduplication + Promise.resolve().then() ensures multiple state mutations within one macrotask trigger exactly one component update. This is foundational to Vue 3's performance.

  3. 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).

  4. nextTick is a microtask chain: It chains after the current flushJobs completes, or after an already-resolved Promise. Understanding this helps correctly reason about DOM stability after nextTick.

  5. Race conditions must be handled with onCleanup + AbortController: Relying on stale flags or ordering assumptions in async watch is unreliable. onCleanup is Vue 3's official cleanup mechanism — combined with AbortController, it fully solves async request race conditions.

Rate this chapter
4.8  / 5  (45 ratings)

💬 Comments