Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 081 / 100

await blocks: Svelte & SvelteKit lessons from production

Async data in markup.

Expona Admin6 min read

The challenge with async state in Svelte templates

Managing asynchronous operations in your markup can feel messy if you're toggling loading states, error flags, and data conditionals across your component. Svelte's await blocks give you a declarative way to handle the entire lifecycle of a promise directly in your template. We've learned in production that this pattern scales better than managing async state imperatively—whether you're fetching user data, processing uploads, or waiting for API responses across e-commerce and SaaS platforms.

In this guide, we'll walk through the patterns behind await blocks: Svelte & SvelteKit lessons from production, covering pending states, success handlers, error recovery, and how top-level await fits into modern component design.

Understanding {#await} for pending state

The {#await} block lets you render content while a promise is still resolving. This is where you typically show a spinner, skeleton, or loading message to your users. Instead of managing a separate loading boolean, you describe the pending UI declaratively.

typescript
<script>
  import { onMount } from 'svelte';

  let userPromise;

  onMount(async () => {
    userPromise = fetch('/api/user').then(r => r.json());
  });
</script>

{#await userPromise}
  <div class="loader">
    <p>Loading user profile...</p>
  </div>
{/await}

The key insight from production systems is that {#await} without {:then} doesn't render anything once the promise resolves. You need to chain handlers to actually display your data. But for intermediate loading states—think a progress indicator that disappears as soon as data arrives—this pattern shines in travel booking flows and ride-hailing dashboards where you want instant feedback.

One real-world scenario: a checkout page fetching shipping estimates. The moment the user selects an address, you fire off the request and show a brief loader. The {#await} block keeps your markup clean and doesn't require you to pollute your script section with extra state variables.

Handling resolved data with {:then}

The {:then} clause is where you bind the resolved promise value and render your actual content. This is the happy path—your data arrived and you're ready to display it.

typescript
<script>
  let userPromise;

  onMount(async () => {
    userPromise = fetch('/api/user').then(r => r.json());
  });
</script>

{#await userPromise}
  <p>Loading...</p>
{:then user}
  <div class="user-card">
    <h3>{user.name}</h3>
    <p>Email: {user.email}</p>
    <p>Account created: {new Date(user.createdAt).toLocaleDateString()}</p>
  </div>
{/await}

The variable binding in {:then user} gives you scoped access to your resolved data. This keeps your template readable and prevents you from needing to store the result in a separate reactive variable. In e-commerce platforms we've shipped, this pattern reduced boilerplate significantly when rendering product lists, order history, and user dashboards.

A production tip: if your promise resolves with an array or object, destructure it in the block for cleaner templates.

typescript
{#await ordersPromise}
  <p>Fetching orders...</p>
{:then { orders, totalCount }}
  <p>Showing {orders.length} of {totalCount} orders</p>
  {#each orders as order (order.id)}
    <div>Order #{order.id}: ${order.total}</div>
  {/each}
{/await}

Error handling with {:catch}

Network requests fail. APIs go down. Users lose connectivity. The {:catch} clause ensures you're not leaving your users staring at a blank screen when things go wrong.

typescript
<script>
  let userPromise;

  onMount(async () => {
    userPromise = fetch('/api/user').then(r => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`);
      return r.json();
    });
  });
</script>

{#await userPromise}
  <p>Loading user...</p>
{:then user}
  <div class="user-card">
    <h3>{user.name}</h3>
  </div>
{:catch error}
  <div class="alert alert-error">
    <p>Failed to load user: {error.message}</p>
    <button on:click={() => (userPromise = fetchUser())}>
      Retry
    </button>
  </div>
{/await}

The error object in {:catch error} gives you access to the thrown exception. In production systems, we log this for debugging and often show a user-friendly message rather than the raw error. The retry button pattern is crucial in mobile-first SaaS—network interruptions happen, and giving users a way to recover without reloading builds trust.

A pattern we've refined across trading and ride-hailing platforms: distinguish between network errors and API errors. A 429 rate limit might trigger a backoff-and-retry flow, while a 401 might redirect to login.

typescript
{:catch error}
  {#if error.status === 401}
    <p>Session expired. <a href="/login">Log in again</a></p>
  {:else if error.status === 429}
    <p>Too many requests. Please wait before retrying.</p>
  {:else}
    <p>An error occurred. <button on:click={...}>Retry</button></p>
  {/if}
{/catch}

Top-level await in components (experimental)

SvelteKit allows top-level await directly in your script block—no promise variable needed. This is marked experimental but increasingly stable. It works beautifully when you're loading data in a layout or page component.

typescript
<script>
  async function loadUser() {
    const res = await fetch('/api/user');
    if (!res.ok) throw new Error('Failed to load user');
    return res.json();
  }

  // Top-level await—the component doesn't render until this resolves
  let userPromise = loadUser();
</script>

{#await userPromise}
  Loading...
{:then user}
  <h2>{user.name}</h2>
{/await}

The experimental nature means you should check the current SvelteKit documentation, but in production SaaS apps we've shipped, top-level await in page components keeps layouts cleaner. The component simply doesn't mount until the data is ready—there's no intermediate loading state at the page level.

In SvelteKit's load functions, you're already working with promises naturally, so you can throw errors for centralized error handling:

typescript
// src/routes/+page.ts
export async function load({ fetch }) {
  const res = await fetch('/api/user');
  if (!res.ok) throw error(res.status, 'Failed to load user');
  return { user: await res.json() };
}

// src/routes/+page.svelte
<script>
  import type { PageData } from './$types';
  export let data: PageData;
</script>

<h2>{data.user.name}</h2>

This separates concerns cleanly: the load function handles data fetching and error throwing, and your component handles presentation. SvelteKit's error handling layer catches thrown errors and renders your error page.

Common mistakes

Forgetting to handle the pending state

Writing only {:then} without {#await} means your users see nothing while the promise is resolving. Always include a pending handler or accept the flash of nothing.

Not re-running the promise when dependencies change

If your async operation depends on a reactive variable, use a reactive statement or effect to update the promise:

typescript
<script>
  let userId;
  let userPromise;

  $: if (userId) {
    userPromise = fetch(`/api/users/${userId}`).then(r => r.json());
  }
</script>

Without this, changing userId won't trigger a new fetch—your await block will keep showing the old data.

Mixing imperative state management with await blocks

Storing promise results in a separate reactive variable alongside the promise itself creates confusion. Pick one approach: either use the await block exclusively or manage state imperatively. Mixing both makes debugging harder.

Not validating HTTP responses

An HTTP 404 or 500 doesn't throw by default in JavaScript's Fetch API. Check response.ok and throw manually so your {:catch} handler fires:

typescript
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();

Key takeaways

  • Use {#await promise} to render pending UI, {:then} for success, and {:catch} for errors in a single declarative block.
  • The resolved value in {:then user} is scoped to the block—no need for extra reactive variables.
  • Always throw errors explicitly (check response.ok) so they propagate to {:catch}.
  • For load functions in SvelteKit pages, throw errors to let the framework handle error boundaries and centralized error pages.
  • Use reactive statements to re-run promises when dependencies change; don't rely on the await block to re-subscribe automatically.
  • Top-level await in components is experimental but increasingly stable—useful for page-level data loading in SvelteKit.
  • Retry logic in {:catch} blocks improves UX on unreliable networks; distinguish between transient and permanent failures.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles