Skip to the solution
HardFrontend11 min read

What is async setup and top-level await?

853 views

The problem

Question presented to candidate: "In one of our pages, a developer wrote const user = await fetchUser(props.id) directly in <script setup>. It works in one place and renders a blank area in another. Explain what top-level await does to a component, what the compiler generates, and the rules that come with it."

What a strong answer should cover:

  • A top-level await in <script setup> turns the component's setup() into an async function; the component then needs a <Suspense> ancestor or it renders nothing (Vue warns).
  • The compiler wraps each awaited expression in withAsyncContext, which restores the current component instance after the await, so onMounted, inject and composables still work after it.
  • A hand-written async setup() gets no such help: hooks registered after the first await are lost with a warning.
  • setup() runs once per instance: a prop change does not re-run the awaited fetch; use a key or a watcher.
  • Errors from the awaited promise go to onErrorCaptured / app.config.errorHandler; on the server, renderToString awaits it and sends complete HTML.

Clarifying questions expected:

  • "Is there a <Suspense> above the place where it renders blank?"
  • "Should the data refresh when props.id changes?"

Code / implementation expected: Yes β€” a component with top-level await inside <Suspense>, ideally showing the compiled output or the hook rule.

  • async
  • setup

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read Β· 10 sections

Read the code

VueAsync setup inside Suspense: hooks before await, and re-keying to refetch
<script setup>
import { ref, defineComponent, h, onMounted } from "vue";

// Simulated API call.
const fetchUser = (id) =>
  new Promise((r) => setTimeout(() => r({ id, name: ["Ada", "Grace", "Linus"][id - 1] }), 40));

// Equivalent to a child .vue file whose <script setup> does: const user = await fetchUser(props.id)
const UserCard = defineComponent({
  props: { id: Number },
  async setup(props) {
    // Hooks go BEFORE the first await in a hand-written async setup().
    onMounted(() => console.log(`  UserCard mounted (id ${props.id})`));
    console.log(`  setup runs, fetching id ${props.id}`);
    const user = await fetchUser(props.id);
    return () => h("p", { class: "card" }, `${user.name} (prop id is now ${props.id})`);
  },
});

const id = ref(1);
const remount = ref(false); // when true, :key forces a fresh setup per id
</script>

<template>
  <button class="next" @click="id = (id % 3) + 1">Next user</button>
  <label><input type="checkbox" class="remount" v-model="remount" /> key by id</label>
  <Suspense>
    <UserCard :id="id" :key="remount ? id : 'same'" />
    <template #fallback><p>Loading user...</p></template>
  </Suspense>
</template>
VueUserCard.vue with top-level await, compiled with withAsyncContext
<script setup>
import { ref, onMounted, getCurrentInstance } from "vue";

const props = defineProps({ id: Number });
console.log("before await, instance?", !!getCurrentInstance());

// Top-level await: this makes the component's setup() async.
const user = await new Promise((r) => setTimeout(() => r({ id: props.id, name: "Ada" }), 30));

console.log("after await,  instance?", !!getCurrentInstance());
const visits = ref(1);
// Registered AFTER the await: the compiler restores the instance, so this works.
onMounted(() => console.log("UserCard mounted with", user.name));
</script>

<template>
  <p class="card">{{ user.name }} (#{{ user.id }}), visits {{ visits }}</p>
</template>
Vitest: Suspense, no Suspense, and hooks after await in a hand-written async setup
import { test, expect } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { Suspense, defineComponent, h, onMounted } from "vue";
import Dashboard from "./Dashboard.vue";
import UserCard from "./UserCard.vue";

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

test("script setup with top-level await inside Suspense", async () => {
  const w = mount(Dashboard);
  console.log("first render:", w.text());
  await sleep(50); await flushPromises();
  console.log("resolved    :", w.text());
  expect(w.text()).toContain("Ada");
});

test("async component without a Suspense boundary", async () => {
  const w = mount(UserCard, { props: { id: 1 } });
  await sleep(50); await flushPromises();
  console.log("no Suspense html:", JSON.stringify(w.html()));
});

test("hand-written async setup(): hooks after await", async () => {
  const Manual = defineComponent({
    async setup() {
      await sleep(10);
      onMounted(() => console.log("never logged"));
      return () => h("p", "manual");
    },
  });
  const w = mount({ render: () => h(Suspense, null, { default: () => h(Manual) }) });
  await sleep(30); await flushPromises();
  console.log("manual:", w.text());
});

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 77 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

What is Suspense in Vue?

Continue