Suspense

Stage: implementation

NOTE

Suspense and out-of-order streaming are experimental. Their API and rendering behavior may still change as the feature develops.

<Suspense> shows a fallback while its content is loading.

When the content is ready, Qwik replaces the fallback with the real content.

Suspense does not fetch data or create async values. It only decides what to show while rendering is paused.

The high-level model is:

useSignal()     // state you set directly
useComputed$()  // state calculated synchronously
useAsync$()     // state resolved from async work
 
<Suspense>      // fallback UI while content loads

The loading work happens elsewhere. Suspense only shows the fallback.

Basic Usage

Wrap loading content with <Suspense> and pass a fallback:

import { component$, Suspense, useSignal, type JSXOutput } from '@qwik.dev/core';
 
const AsyncMessage = component$(() => {
  const content = new Promise<JSXOutput>((resolve) => {
    setTimeout(() => resolve(<p>Async content resolved.</p>), 1000);
  });
 
  return <>{content}</>;
});
 
export default component$(() => {
  const show = useSignal(false);
 
  return (
    <section>
      <button onClick$={() => (show.value = !show.value)}>
        {show.value ? 'Hide' : 'Show'} content
      </button>
 
      {show.value && (
        <Suspense fallback={<p>Loading content...</p>} delay={150}>
          <AsyncMessage />
        </Suspense>
      )}
    </section>
  );
});

While AsyncMessage is loading, Qwik shows the fallback. Once it resolves, Qwik shows the real content.

With useAsync$()

The async signal loads the data. Suspense shows the fallback while user.value is not ready.

import { component$, Suspense, useAsync$, useSignal } from '@qwik.dev/core';
 
type User = {
  name: {
    first: string;
    last: string;
  };
  email: string;
};
 
const UserCard = component$(() => {
  const user = useAsync$(async ({ abortSignal }) => {
    const response = await fetch('https://randomuser.me/api/', {
      signal: abortSignal,
    });
    const data = (await response.json()) as {
      results: User[];
    };
 
    return data.results[0];
  });
 
  return (
    <p>
      User: {user.value.name.first} {user.value.name.last} ({user.value.email})
    </p>
  );
});
 
export default component$(() => {
  const show = useSignal(false);
 
  return (
    <section>
      <button onClick$={() => (show.value = !show.value)}>
        {show.value ? 'Hide user' : 'Load random user'}
      </button>
 
      {show.value && (
        <Suspense fallback={<p>Loading user...</p>}>
          <UserCard />
        </Suspense>
      )}
    </section>
  );
});

When UserCard reads user.value before the request finishes, Qwik shows the nearest <Suspense> fallback.

Inline .loading and .error checks are still useful when loading, error, and success states need different markup. Suspense works well when one fallback can cover the whole section.

What Can Trigger Suspense

Suspense can show its fallback when content inside it is still loading, including:

  • reading a useAsync$() value before it is ready
  • returning a Promise from JSX
  • rendering a child component that is still loading
  • running descendant work that blocks rendering

Suspense does not do the loading work. It only controls the fallback while that work finishes.

Out-of-order Streaming

Out-of-order streaming is an optional server rendering mode for <Suspense>.

Server rendering means Qwik creates HTML on the server and sends it to the browser. Streaming means the browser can receive that HTML in chunks instead of waiting for one complete HTML string.

Out-of-order streaming helps when one section of the page is slow, but the rest of the page is ready.

Without out-of-order streaming, the server renders the page in order. If a Suspense child is still waiting, the server waits at that point before it can continue sending the next HTML.

With out-of-order streaming, Qwik can:

  1. Send the HTML that is ready.
  2. Send the Suspense fallback for the slow section.
  3. Continue sending the rest of the page, such as the footer or nearby buttons.
  4. Send the real Suspense content later, when it finishes.
  5. Replace the fallback with the real content in the browser.

From the user's point of view, the page appears sooner. They can see the shell of the page while one slower section is still loading.

NOTE

Out-of-order streaming only applies to promises inside <Suspense>. A promise outside Suspense still follows the normal SSR rendering behavior.

You do not need to write the streaming markers or replacement scripts yourself. Keep writing <Suspense> and fallbacks normally; Qwik handles the streamed HTML and browser-side replacement.

Enable Suspense

Suspense is experimental, so first enable it in vite.config.ts:

vite.config.ts
import { qwikVite } from '@qwik.dev/core/optimizer';
import { qwikRouter } from '@qwik.dev/router/vite';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
 
export default defineConfig(() => ({
  plugins: [
    qwikRouter(),
    qwikVite({
      experimental: ['suspense'],
    }),
    tsconfigPaths(),
  ],
}));

Enable Out-of-order Streaming

Then enable out-of-order streaming in your server entry point.

Most Qwik apps have a src/entry.ssr.tsx file. That file receives render options from the adapter and returns the JSX and options that should be rendered.

Add streaming.outOfOrder to those options:

src/entry.ssr.tsx
import { createRenderer } from '@qwik.dev/router';
import Root from './root';
 
export default createRenderer((opts) => {
  return {
    jsx: <Root />,
    options: {
      ...opts,
      streaming: {
        ...opts.streaming,
        outOfOrder: { strategy: 'suspense' },
      },
    },
  };
});

Out-of-order streaming is disabled by default. The enabled value is:

outOfOrder: { strategy: 'suspense' }

To turn it off again, remove the option or use:

outOfOrder: { strategy: 'disabled' }

If your project calls renderToStream() directly, pass the same streaming option to renderToStream():

src/entry.ssr.tsx
import { renderToStream, type RenderToStreamOptions } from '@qwik.dev/core/server';
import Root from './root';
 
export default function (opts: RenderToStreamOptions) {
  return renderToStream(<Root />, {
    ...opts,
    streaming: {
      ...opts.streaming,
      outOfOrder: { strategy: 'suspense' },
    },
  });
}

Example

In this example, the page shell and fallback can stream before ProfileDetails finishes:

import { component$, Suspense, useAsync$ } from '@qwik.dev/core';
 
const ProfileDetails = component$(() => {
  const profile = useAsync$(async ({ abortSignal }) => {
    const response = await fetch('/api/profile', {
      signal: abortSignal,
    });
 
    return response.json() as Promise<{
      name: string;
      plan: string;
    }>;
  });
 
  return (
    <section>
      <h2>{profile.value.name}</h2>
      <p>Plan: {profile.value.plan}</p>
    </section>
  );
});
 
export default component$(() => {
  return (
    <main>
      <h1>Account</h1>
 
      <Suspense fallback={<p>Loading profile...</p>}>
        <ProfileDetails />
      </Suspense>
 
      <footer>Need help? Contact support.</footer>
    </main>
  );
});

If /api/profile is slow, Qwik can send:

  • the Account heading
  • the Loading profile... fallback
  • the footer

Then, when the profile request resolves, Qwik sends the finished profile HTML and swaps it into the Suspense boundary.

If the profile request finishes before the server needs to stream the fallback, Qwik can send the finished profile directly and the fallback may never appear.

Fallbacks

The fallback is real UI. Choose markup that is useful while the user waits:

<Suspense fallback={<p>Loading recommendations...</p>}>
  <Recommendations />
</Suspense>

If you do not provide a fallback, the boundary has no visible loading UI. The rest of the page can still stream, but that section will appear empty until the content is ready.

When out-of-order streaming is enabled, a boundary that suspends during the initial server render streams its fallback as soon as Qwik reaches that boundary. The delay prop is still useful for client-side waits and later updates, but it is not used as a timer for the initial server stream.

For best results:

  • keep fallbacks small
  • reserve enough space to avoid layout shift
  • use accessible text, not only a spinner
  • avoid important actions that disappear immediately when the real content arrives

Interactivity

Qwik keeps the streamed HTML resumable.

That means the shell of the page can become interactive even while a Suspense section is still waiting. If your fallback has buttons or other event handlers, they can also resume while the fallback is visible, after the root page state has loaded in the browser.

When the real content arrives, Qwik removes the fallback and inserts the resolved content. The resolved content may be visible before it is interactive. Qwik resumes that content after the root page state and the content's streamed state have both loaded, then its event handlers and state work like any other server-rendered Qwik HTML.

When to Use It

Out-of-order streaming is useful for independent page sections that may be slower than the rest of the page, such as:

  • account panels
  • recommendations
  • reviews
  • dashboards
  • comments
  • secondary content below the main heading

It is less useful when the slow content is required before the rest of the page makes sense. In that case, showing a complete page a little later may be clearer than streaming a partial page early.

Static Builds

Out-of-order streaming is an SSR feature. It helps when the browser is receiving an HTTP response while the server is still rendering.

For static site generation, the HTML file is created ahead of time. The user receives the finished file later, so there is no live server stream to reveal a fallback first.

Troubleshooting

If you see an error that says Suspense must be enabled, add experimental: ['suspense'] to qwikVite().

If the fallback never appears, check these common causes:

  • the content resolves before the browser receives the fallback
  • the slow promise is outside <Suspense>
  • there is no fallback prop
  • the hosting platform or proxy buffers the whole response instead of streaming it

If your site uses a Content Security Policy with script nonces, keep passing the nonce through your SSR render options. Qwik will add the nonce to the inline scripts it uses for out-of-order streaming.

delay

The delay prop waits before showing the fallback:

import {
  component$,
  Suspense,
  useSignal,
  type JSXOutput,
} from '@qwik.dev/core';
 
const LOAD_MS = 2500;
const FALLBACK_DELAY_MS = 1000;
 
const SlowContent = component$(() => (
  <>
    {new Promise<JSXOutput>((resolve) => {
      setTimeout(() => resolve(<p>Loaded content.</p>), LOAD_MS);
    })}
  </>
));
 
export default component$(() => {
  const run = useSignal(0);
  const elapsed = useSignal(0);
 
  return (
    <section>
      <button
        disabled={run.value > 0 && elapsed.value < LOAD_MS}
        onClick$={() => {
          run.value++;
          elapsed.value = 0;
 
          const start = Date.now();
          const timer = setInterval(() => {
            elapsed.value = Math.min(Date.now() - start, LOAD_MS);
 
            if (elapsed.value === LOAD_MS) {
              clearInterval(timer);
            }
          }, 100);
        }}
      >
        Load content
      </button>
 
      {run.value > 0 && (
        <Suspense
          fallback={<p>Fallback shown after {FALLBACK_DELAY_MS}ms.</p>}
          delay={FALLBACK_DELAY_MS}
        >
          <SlowContent key={run.value} />
        </Suspense>
      )}
 
      <p>Elapsed: {elapsed.value}ms</p>
    </section>
  );
});

Use delay to avoid flashing a loading state for work that usually resolves quickly.

If the content resolves before the delay finishes, the fallback is not shown.

showStale

By default, when a boundary that already showed content pauses again, the fallback replaces the content while the new work is pending.

Use showStale to keep the previous content visible while also showing the fallback:

import { component$, Suspense, useSignal, type JSXOutput } from '@qwik.dev/core';
 
const LOAD_MS = 1200;
 
const COLORS = ['#7c3aed', '#0891b2', '#16a34a', '#ea580c'];
 
const ProfileCard = component$((props: { version: number }) => {
  const content = new Promise<JSXOutput>((resolve) => {
    const color = COLORS[props.version % COLORS.length];
 
    setTimeout(
      () =>
        resolve(
          <article style={{ border: `4px solid ${color}`, padding: '12px' }}>
            <p>Profile version {props.version}</p>
          </article>
        ),
      LOAD_MS
    );
  });
 
  return <>{content}</>;
});
 
export default component$(() => {
  const version = useSignal(1);
 
  return (
    <section>
      <button onClick$={() => version.value++}>Refresh profile</button>
 
      <Suspense fallback={<p>Loading new profile...</p>} showStale>
        <ProfileCard version={version.value} />
      </Suspense>
    </section>
  );
});

Click โ€œRefresh profileโ€ after the first card appears. The old card stays visible alongside the fallback while the new card loads.

showStale only affects content that has already been revealed. During the first render, there is no previous content to keep visible, so the boundary only shows the fallback while it waits.

NOTE

showStale keeps previously revealed content visible while the same content updates. If you change a child component's key, Qwik treats it as a new instance, so there may be no stale content to keep.

showStale only controls what the user sees. It does not cache data or change what the subtree returns.

Coordinating Boundaries with Reveal

Use <Reveal> when a group of sibling <Suspense> boundaries should reveal in a specific order.

import { component$, Reveal, Suspense } from '@qwik.dev/core';
 
export default component$(() => {
  return (
    <Reveal order="sequential" collapsed>
      <Suspense fallback={<p>Loading profile...</p>}>
        <Profile />
      </Suspense>
 
      <Suspense fallback={<p>Loading activity...</p>}>
        <Activity />
      </Suspense>
    </Reveal>
  );
});

Reveal does not start or resolve async work. It only coordinates which registered boundary is allowed to show its content.

Reveal order options:

  • parallel: boundaries reveal independently
  • sequential: later boundaries wait for earlier pending boundaries
  • reverse: earlier boundaries wait for later pending boundaries
  • together: no boundary reveals content until all boundaries are ready

By default, a pending boundary that is blocked by Reveal can still show its fallback. Add collapsed to hide blocked pending boundaries completely until they are allowed to reveal.

API

type SuspenseProps = {
  fallback?: JSXOutput;
  delay?: number;
  showStale?: boolean;
};
 
type RevealOrder = 'parallel' | 'sequential' | 'reverse' | 'together';
 
type RevealProps = {
  order?: RevealOrder;
  collapsed?: boolean;
};

Props:

  • fallback: JSX rendered while the boundary is waiting
  • delay: milliseconds to wait before showing the fallback, defaults to 0
  • showStale: keep previously revealed content visible while showing the fallback during later waits
  • order: reveal order for child Suspense boundaries, defaults to parallel
  • collapsed: hide pending blocked boundaries instead of showing their fallbacks

Suspense Is Not an Error Boundary

<Suspense> only handles content that is still loading. It does not catch errors thrown by child components.

For errors thrown by descendants, reach for useErrorBoundary().

Choosing the Right Primitive

APIBest for
useAsync$()Async data or other async work that should produce a signal
<Suspense>Showing one fallback while a section is loading
useTask$()Running setup or update code that writes to existing state or performs a side effect
routeLoader$()Loading route data before the route renders

For async work inside a component, useAsync$() is usually the starting point. Add <Suspense> when one fallback can cover the loading state for a section. Keep inline .loading and .error checks when each state needs different markup.