skip to content

In Vue's <script setup>, what does a top-level await compile to, and why does the compiler rewrite each such await?

level: middleimportance: nice to knowfreq 28%

answer

  1. the setup function becomes async
  2. the current instance after a pause
  3. hooks registered after the await
  4. a boundary above is required

basics

~20 s

A top-level await makes the compiled setup() an async setup(). The compiler wraps each top-level awaited expression with Vue's withAsyncContext helper so the current component instance is restored after the await, letting hooks and watchers registered later still attach to the component.

solid answer

~40 s

`<script setup>` allows a top-level `await`, and the compiled result is an `async setup()`. The catch is Vue's **current instance**: lifecycle hooks such as `onMounted`, watchers and `inject` find their component through a module-level current-instance pointer that is only set while setup runs synchronously. After an `await` the function resumes in a later microtask, when that pointer is unset or points at another component. So the compiler rewrites each top-level `await expr` into a call through the internal `withAsyncContext` helper, which captures the instance before the await and restores it afterwards. That is why hooks registered after a top-level await still work in `<script setup>`, while in a hand-written `async setup()` they must be registered before the first `await`. An async setup also requires a `<Suspense>` boundary above the component to render.

code

vue · 15 lines
vue
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const user = ref(await fetch('/api/me').then((r) => r.json()))

// Registered after the await: works because the compiler
// restores the current instance after each top-level await
onMounted(() => {
  document.title = `Hi, ${user.value.name}`
})
</script>

<template>
  <p>{{ user.name }}</p>
</template>

go deeper

for a junior

Recall that await is allowed at the top level of <script setup> and makes the component's setup asynchronous.

for a middle

Explain the current-instance problem after an await and how the compiler's withAsyncContext rewrite restores the instance for later hooks.

for a senior

Know the limits: only top-level awaits are rewritten, hand-written async setup and composables must register hooks before awaiting, and a Suspense boundary is required.

for a principal

Decide whether async setup plus Suspense or explicit loading state inside components fits your data-loading strategy, given Suspense's experimental status.

## The feature `<script setup>` supports a **top-level `await`**: ```vue <script setup> const post = await fetch('/api/post/1').then((r) => r.json()) </script> ``` Because the block is the body of `setup()`, an `await` at its top level makes the compiled function **`async setup()`**. A component with an async setup renders only under a `<Suspense>` boundary, which waits for it to resolve; how that boundary behaves is a separate topic. ## The problem the rewrite solves: the current instance Many Composition API functions need to know which component they belong to: - lifecycle hooks such as `onMounted` and `onUnmounted`; - `watch` and `watchEffect`, which are stopped automatically when their component unmounts; - `provide` and `inject`; - `useAttrs`, `useSlots` and similar helpers. They find it through a **current component instance** that Vue sets just before calling `setup()` and unsets right after. That works for synchronous code. An `await` suspends the function, and the rest runs later, in a microtask, when Vue has already unset the pointer or set it to another component that is being set up in the meantime. Without help, a hook registered after the first `await` finds no instance. Vue warns that the hook is called when there is no active component instance, adding that with async `setup()` you should register lifecycle hooks before the first `await` statement. ## What the compiler emits The SFC compiler rewrites every top-level `await` in `<script setup>` into a call through an internal helper, `withAsyncContext`: ```js // source const post = await loadPost() // compiled, roughly let __temp, __restore const post = (([__temp, __restore] = withAsyncContext(() => loadPost())), __temp = await __temp, __restore(), __temp) ``` Step by step: 1. `withAsyncContext` captures the current instance and starts the awaited operation. 2. It unsets the current instance, so unrelated code running during the pause cannot attach to this component. 3. After the `await` resolves, `__restore()` sets the captured instance back. 4. Code after the `await`, including `onMounted` or `watch` calls, now registers against the right component. The Vue docs summarise this as: the awaited expression is automatically compiled in a format that preserves the current component instance context after the `await`. ## What the rewrite does not cover | Code | Instance restored after the await? | |---|---| | top-level `await` in `<script setup>` | yes, the compiler rewrites it | | `await` inside a nested function or callback defined in the block | no, only top-level awaits are rewritten | | hand-written `async setup()` in a normal `<script>` | no, register hooks before the first `await` | | `await` inside a composable called after a top-level await | the composable's own internal awaits are not rewritten | ## Interview traps - **"await makes the template wait."** The template does not wait; the whole component waits, because its setup has not finished, and nothing of it renders until then. - **"Any await is safe in script setup."** Only top-level awaits get the instance restored; nested ones do not. - **"withAsyncContext is something I call."** It is an internal helper the compiler inserts; application code does not call it. - **Error handling.** A rejected top-level await rejects the async setup; Vue catches that rejection and reports it through its component error handling, so `errorCaptured` hooks and the app-level error handler see it, not code inside the component. ## Practical guidance - **Register hooks early when you can.** Even with the rewrite, putting `onMounted` and `watch` calls before the first `await` makes the component easier to read and removes any dependence on the rewrite. - **Mind the render delay.** The component does not render until its setup promise resolves, so the loading state is decided by the `<Suspense>` boundary, not by the component. - **Composables that await internally** should register their hooks before their own first `await`, because the compiler does not rewrite code outside `<script setup>`. ## Summary A top-level `await` turns the compiled setup into `async setup()`, and the compiler wraps each such `await` with `withAsyncContext` so the current instance is restored afterwards. That keeps later hooks and watchers bound to the component, a guarantee a hand-written async setup does not get.

  • The same onMounted-after-await code in a hand-written async setup() logs a Vue warning. Which warning, and why?
    Vue warns that `onMounted` is called when there is no active component instance, and suggests registering lifecycle hooks before the first `await`. A hand-written `async setup()` is not rewritten, so after the `await` the current instance is no longer set and the hook cannot attach to the component.
  • Does the rewrite also protect an await inside a function declared in <script setup>?
    No. The compiler rewrites only top-level `await` expressions of the block. An `await` inside a nested function or callback runs later without instance restoration, so hooks or `inject` calls after it will not find the component.

It is like a cloakroom ticket: before stepping out to wait, the compiler takes a ticket for the component that was current, and when the wait ends it hands the ticket back so everything registered next is filed under the right component.

saying these in an interview costs you the question

  • A top-level await leaves setup synchronous; Vue waits for the promise in the template.
  • Hooks registered after any await in any setup function always attach correctly.
  • The compiler rewrites awaits inside nested functions and composables too.
  • A component with top-level await renders normally without any Suspense boundary.
  • withAsyncContext is a public API you are expected to call yourself.