diff --git a/packages/runtime-core/__tests__/apiWatch.spec.ts b/packages/runtime-core/__tests__/apiWatch.spec.ts
index 7b64e50bd5f..31bca6bed3f 100644
--- a/packages/runtime-core/__tests__/apiWatch.spec.ts
+++ b/packages/runtime-core/__tests__/apiWatch.spec.ts
@@ -280,22 +280,29 @@ describe('api: watch', () => {
     expect(cleanup).toHaveBeenCalledTimes(2)
   })
 
-  it('flush timing: post (default)', async () => {
+  it('flush timing: pre (default)', async () => {
     const count = ref(0)
+    const count2 = ref(0)
+
     let callCount = 0
-    let result
-    const assertion = jest.fn(count => {
+    let result1
+    let result2
+    const assertion = jest.fn((count, count2Value) => {
       callCount++
       // on mount, the watcher callback should be called before DOM render
-      // on update, should be called after the count is updated
-      const expectedDOM = callCount === 1 ? `` : `${count}`
-      result = serializeInner(root) === expectedDOM
+      // on update, should be called before the count is updated
+      const expectedDOM = callCount === 1 ? `` : `${count - 1}`
+      result1 = serializeInner(root) === expectedDOM
+
+      // in a pre-flush callback, all state should have been updated
+      const expectedState = callCount - 1
+      result2 = count === expectedState && count2Value === expectedState
     })
 
     const Comp = {
       setup() {
         watchEffect(() => {
-          assertion(count.value)
+          assertion(count.value, count2.value)
         })
         return () => count.value
       }
@@ -303,42 +310,32 @@ describe('api: watch', () => {
     const root = nodeOps.createElement('div')
     render(h(Comp), root)
     expect(assertion).toHaveBeenCalledTimes(1)
-    expect(result).toBe(true)
+    expect(result1).toBe(true)
+    expect(result2).toBe(true)
 
     count.value++
+    count2.value++
     await nextTick()
+    // two mutations should result in 1 callback execution
     expect(assertion).toHaveBeenCalledTimes(2)
-    expect(result).toBe(true)
+    expect(result1).toBe(true)
+    expect(result2).toBe(true)
   })
 
-  it('flush timing: pre', async () => {
+  it('flush timing: post', async () => {
     const count = ref(0)
-    const count2 = ref(0)
-
-    let callCount = 0
-    let result1
-    let result2
-    const assertion = jest.fn((count, count2Value) => {
-      callCount++
-      // on mount, the watcher callback should be called before DOM render
-      // on update, should be called before the count is updated
-      const expectedDOM = callCount === 1 ? `` : `${count - 1}`
-      result1 = serializeInner(root) === expectedDOM
-
-      // in a pre-flush callback, all state should have been updated
-      const expectedState = callCount - 1
-      result2 = count === expectedState && count2Value === expectedState
+    let result
+    const assertion = jest.fn(count => {
+      result = serializeInner(root) === `${count}`
     })
 
     const Comp = {
       setup() {
         watchEffect(
           () => {
-            assertion(count.value, count2.value)
+            assertion(count.value)
           },
-          {
-            flush: 'pre'
-          }
+          { flush: 'post' }
         )
         return () => count.value
       }
@@ -346,16 +343,12 @@ describe('api: watch', () => {
     const root = nodeOps.createElement('div')
     render(h(Comp), root)
     expect(assertion).toHaveBeenCalledTimes(1)
-    expect(result1).toBe(true)
-    expect(result2).toBe(true)
+    expect(result).toBe(true)
 
     count.value++
-    count2.value++
     await nextTick()
-    // two mutations should result in 1 callback execution
     expect(assertion).toHaveBeenCalledTimes(2)
-    expect(result1).toBe(true)
-    expect(result2).toBe(true)
+    expect(result).toBe(true)
   })
 
   it('flush timing: sync', async () => {
@@ -410,7 +403,7 @@ describe('api: watch', () => {
     const cb = jest.fn()
     const Comp = {
       setup() {
-        watch(toggle, cb)
+        watch(toggle, cb, { flush: 'post' })
       },
       render() {}
     }
diff --git a/packages/runtime-core/__tests__/components/Suspense.spec.ts b/packages/runtime-core/__tests__/components/Suspense.spec.ts
index e4a57804314..dd7d67bc909 100644
--- a/packages/runtime-core/__tests__/components/Suspense.spec.ts
+++ b/packages/runtime-core/__tests__/components/Suspense.spec.ts
@@ -154,7 +154,7 @@ describe('Suspense', () => {
     expect(onResolve).toHaveBeenCalled()
   })
 
-  test('buffer mounted/updated hooks & watch callbacks', async () => {
+  test('buffer mounted/updated hooks & post flush watch callbacks', async () => {
     const deps: Promise<any>[] = []
     const calls: string[] = []
     const toggle = ref(true)
@@ -165,14 +165,21 @@ describe('Suspense', () => {
         // extra tick needed for Node 12+
         deps.push(p.then(() => Promise.resolve()))
 
-        watchEffect(() => {
-          calls.push('immediate effect')
-        })
+        watchEffect(
+          () => {
+            calls.push('watch effect')
+          },
+          { flush: 'post' }
+        )
 
         const count = ref(0)
-        watch(count, () => {
-          calls.push('watch callback')
-        })
+        watch(
+          count,
+          () => {
+            calls.push('watch callback')
+          },
+          { flush: 'post' }
+        )
         count.value++ // trigger the watcher now
 
         onMounted(() => {
@@ -201,12 +208,12 @@ describe('Suspense', () => {
     const root = nodeOps.createElement('div')
     render(h(Comp), root)
     expect(serializeInner(root)).toBe(`<div>fallback</div>`)
-    expect(calls).toEqual([`immediate effect`])
+    expect(calls).toEqual([])
 
     await Promise.all(deps)
     await nextTick()
     expect(serializeInner(root)).toBe(`<div>async</div>`)
-    expect(calls).toEqual([`immediate effect`, `watch callback`, `mounted`])
+    expect(calls).toEqual([`watch effect`, `watch callback`, `mounted`])
 
     // effects inside an already resolved suspense should happen at normal timing
     toggle.value = false
@@ -214,7 +221,7 @@ describe('Suspense', () => {
     await nextTick()
     expect(serializeInner(root)).toBe(`<!---->`)
     expect(calls).toEqual([
-      `immediate effect`,
+      `watch effect`,
       `watch callback`,
       `mounted`,
       'unmounted'
@@ -319,14 +326,21 @@ describe('Suspense', () => {
         const p = new Promise(r => setTimeout(r, 1))
         deps.push(p)
 
-        watchEffect(() => {
-          calls.push('immediate effect')
-        })
+        watchEffect(
+          () => {
+            calls.push('watch effect')
+          },
+          { flush: 'post' }
+        )
 
         const count = ref(0)
-        watch(count, () => {
-          calls.push('watch callback')
-        })
+        watch(
+          count,
+          () => {
+            calls.push('watch callback')
+          },
+          { flush: 'post' }
+        )
         count.value++ // trigger the watcher now
 
         onMounted(() => {
@@ -355,7 +369,7 @@ describe('Suspense', () => {
     const root = nodeOps.createElement('div')
     render(h(Comp), root)
     expect(serializeInner(root)).toBe(`<div>fallback</div>`)
-    expect(calls).toEqual(['immediate effect'])
+    expect(calls).toEqual([])
 
     // remove the async dep before it's resolved
     toggle.value = false
@@ -366,8 +380,8 @@ describe('Suspense', () => {
     await Promise.all(deps)
     await nextTick()
     expect(serializeInner(root)).toBe(`<!---->`)
-    // should discard effects (except for immediate ones)
-    expect(calls).toEqual(['immediate effect', 'unmounted'])
+    // should discard effects (except for unmount)
+    expect(calls).toEqual(['unmounted'])
   })
 
   test('unmount suspense after resolve', async () => {
diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts
index ac1b1d16d7f..14253a2a403 100644
--- a/packages/runtime-core/src/apiWatch.ts
+++ b/packages/runtime-core/src/apiWatch.ts
@@ -268,9 +268,10 @@ function doWatch(
   let scheduler: (job: () => any) => void
   if (flush === 'sync') {
     scheduler = job
-  } else if (flush === 'pre') {
-    // ensure it's queued before component updates (which have positive ids)
-    job.id = -1
+  } else if (flush === 'post') {
+    scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
+  } else {
+    // default: 'pre'
     scheduler = () => {
       if (!instance || instance.isMounted) {
         queuePreFlushCb(job)
@@ -280,8 +281,6 @@ function doWatch(
         job()
       }
     }
-  } else {
-    scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
   }
 
   const runner = effect(getter, {
@@ -300,6 +299,8 @@ function doWatch(
     } else {
       oldValue = runner()
     }
+  } else if (flush === 'post') {
+    queuePostRenderEffect(runner, instance && instance.suspense)
   } else {
     runner()
   }
diff --git a/packages/runtime-core/src/components/KeepAlive.ts b/packages/runtime-core/src/components/KeepAlive.ts
index 3259ca685c6..ac6eec56f2e 100644
--- a/packages/runtime-core/src/components/KeepAlive.ts
+++ b/packages/runtime-core/src/components/KeepAlive.ts
@@ -171,15 +171,18 @@ const KeepAliveImpl = {
       keys.delete(key)
     }
 
+    // prune cache on include/exclude prop change
     watch(
       () => [props.include, props.exclude],
       ([include, exclude]) => {
         include && pruneCache(name => matches(include, name))
         exclude && pruneCache(name => !matches(exclude, name))
-      }
+      },
+      // prune post-render after `current` has been updated
+      { flush: 'post' }
     )
 
-    // cache sub tree in beforeMount/Update (i.e. right after the render)
+    // cache sub tree after render
     let pendingCacheKey: CacheKey | null = null
     const cacheSubtree = () => {
       // fix #1621, the pendingCacheKey could be 0