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 hides the fallback and shows 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, within the same HTTP connection, 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 that 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. Hide the fallback and show the real content in the browser.

During SSR, Qwik includes the fallback in the stream when the boundary has a fallback. Qwik hides that fallback when the resolved content is shown. It stays in the DOM; it is not removed.

If the boundary has a positive delay, Qwik still sends the fallback HTML immediately, but starts it hidden. If the delay finishes before the content, Qwik reveals the fallback. If the content finishes before the delay, the fallback remains hidden and the resolved content is shown instead.

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 reveal scripts yourself. Keep writing <Suspense> and fallbacks normally; Qwik handles the streamed HTML and browser-side reveal.

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(),
  ],
}));

Configure Out-of-order Streaming

After Suspense is enabled, out-of-order streaming is enabled by default. You can also set it explicitly 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 if you want to set it directly:

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: true,
      },
    },
  };
});

The enabled value is:

outOfOrder: true

To turn it off again, use:

outOfOrder: false

If your project calls renderToStream() directly, pass the same streaming option when you want to set it explicitly:

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: true,
    },
  });
}

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 shows it in the Suspense boundary.

If the profile request finishes quickly, Qwik still sends the fallback during SSR, but the finished profile can be shown before the user notices it.

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. Without a positive delay, the fallback can be visible immediately. With a positive delay, the fallback is streamed hidden and is only revealed if the delay finishes before the real content.

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 hides the fallback and shows the resolved content. The fallback stays in the DOM. 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.

For this reason, Qwik Router disables out-of-order streaming during SSG. Static pages are written as complete HTML files even if the server entry point sets streaming.outOfOrder to true.

Troubleshooting

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

If the fallback is not visible, check these common causes:

  • the resolved content is shown before the browser paints 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.

During out-of-order SSR streaming, Qwik still sends the fallback HTML immediately so the browser can reveal it without waiting for another HTML chunk. For delay > 0, the fallback starts hidden. If the delay finishes first, Qwik reveals it with a streamed update. If the content finishes first, Qwik cancels the reveal and shows the resolved content instead.

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.