Declarative Asynchronous UI in Svelte 5

Modern web applications are fundamentally asynchronous. Users expect instant feedback while data loads from APIs, files upload to servers, and complex computations complete in the background. Managing these asynchronous operations—showing loading spinners, displaying results, handling failures—has traditionally required verbose imperative code that obscures the actual UI logic.

Svelte 5’s #await block transforms this challenge into an elegant declarative pattern. Instead of manually tracking promise states with boolean flags and try-catch blocks, you describe what should render in each state directly in your template. The framework handles the state machine internally, ensuring your UI always reflects the current reality of your asynchronous operations.

This tutorial explores the #await block comprehensively: from basic usage patterns to advanced composition techniques, from graceful error recovery to optimistic UI updates. By the end, you’ll understand not just the syntax, but the mental model for building robust asynchronous interfaces.


The Problem

Managing Promise States Imperatively

Many developers start by handling asynchronous data with manual state management. The following example shows how you might fetch user data and track loading, error, and result states imperatively. This approach works, but quickly becomes verbose and error-prone as your app grows.

<script>
	let user = $state(null)
	let loading = $state(true)
	let error = $state(null)

	async function fetchUser(id) {
		loading = true
		error = null
		user = null

		try {
			const response = await fetch(`/api/users/${id}`)
			if (!response.ok) throw new Error('Failed to fetch user')
			user = await response.json()
		} catch (err) {
			error = err
		} finally {
			loading = false
		}
	}

	$effect(() => {
		fetchUser(1)
	})
</script>

{#if loading}
	<div class="skeleton">Loading...</div>
{:else if error}
	<div class="error">Error: {error.message}</div>
{:else if user}
	<div class="user-card">
		<h2>{user.name}</h2>
		<p>{user.email}</p>
	</div>
{/if}

This works, but it’s verbose. You’re manually managing three pieces of state (loading, error, user) that are inherently connected. You must remember to reset them appropriately, handle the finally block correctly, and coordinate the conditional rendering. As your application grows with multiple async operations, this boilerplate multiplies.

The #await block eliminates this ceremony entirely.


Basic Syntax

The Three States of a Promise

Svelte’s #await block is designed to map directly to the three possible states of a JavaScript Promise. Understanding these states is key to using the block effectively.

  1. Pending: The operation is in progress
  2. Fulfilled: The operation completed successfully with a value
  3. Rejected: The operation failed with an error

The #await block maps directly to these states:

{#await promise}
	<!-- Pending: promise is still resolving -->
	<p>Loading...</p>
{:then value}
	<!-- Fulfilled: promise resolved successfully -->
	<p>The result is: {value}</p>
{:catch error}
	<!-- Rejected: promise was rejected -->
	<p>Error: {error.message}</p>
{/await}

Let’s see this in action:

Below is a practical example using the #await block to fetch user data. Notice how the UI automatically updates as the promise state changes, without manual state management.

<script>
	let userId = $state(1)

	async function fetchUser(id) {
		const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
		if (!response.ok) throw new Error(`HTTP ${response.status}`)
		return response.json()
	}

	// Derived promise that re-fetches when userId changes
	let userPromise = $derived(fetchUser(userId))
</script>

<div class="user-selector">
	<label>
		User ID:
		<input type="number" bind:value={userId} min="1" max="10" />
	</label>
</div>

{#await userPromise}
	<div class="loading-card">
		<div class="skeleton-avatar"></div>
		<div class="skeleton-text"></div>
		<div class="skeleton-text short"></div>
	</div>
{:then user}
	<div class="user-card">
		<h2>{user.name}</h2>
		<p class="email">{user.email}</p>
		<p class="company">{user.company.name}</p>
	</div>
{:catch error}
	<div class="error-card">
		<span class="icon">⚠️</span>
		<p>Failed to load user: {error.message}</p>
		<button onclick={() => (userId = userId)}>Retry</button>
	</div>
{/await}

<style>
	.loading-card,
	.user-card,
	.error-card {
		padding: 1.5rem;
		border-radius: 8px;
		margin-top: 1rem;
	}

	.loading-card {
		background: #f1f5f9;
	}

	.skeleton-avatar {
		width: 60px;
		height: 60px;
		border-radius: 50%;
		background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
		background-size: 200% 100%;
		animation: shimmer 1.5s infinite;
	}

	.skeleton-text {
		height: 1rem;
		margin-top: 0.75rem;
		background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
		background-size: 200% 100%;
		animation: shimmer 1.5s infinite;
		border-radius: 4px;
	}

	.skeleton-text.short {
		width: 60%;
	}

	@keyframes shimmer {
		0% {
			background-position: 200% 0;
		}
		100% {
			background-position: -200% 0;
		}
	}

	.user-card {
		background: white;
		border: 1px solid #e2e8f0;
	}

	.error-card {
		background: #fef2f2;
		border: 1px solid #fecaca;
		color: #dc2626;
		text-align: center;
	}
</style>

When userId changes, Svelte creates a new promise, and the {#await} block automatically transitions through its states. No manual state management required.


Await Expressions in Templates

Svelte 5 introduces a powerful shorthand: you can use await directly in template expressions. This is especially useful for simple async values where you don’t need loading or error states.

<script>
	async function getGreeting() {
		const response = await fetch('/api/greeting')
		return response.json()
	}
</script>

<!-- Shorthand: await directly in expression -->
<h1>{await getGreeting()}</h1>

<!-- Equivalent to: -->
{#await getGreeting() then greeting}
	<h1>{greeting}</h1>
{/await}

Synchronized Updates

When multiple await expressions depend on the same async value, Svelte synchronizes their updates to prevent visual inconsistency:

<script>
	async function fetchUser(id) {
		const res = await fetch(`/api/users/${id}`)
		return res.json()
	}

	let userId = $state(1)
	let userPromise = $derived(fetchUser(userId))
</script>

<!-- Both update together, not one at a time -->
<h1>Welcome, {(await userPromise).name}!</h1>
<p>Email: {(await userPromise).email}</p>

When to Use Which

PatternUse Case
{await expr}Simple display, no loading/error UI needed
{#await} blockNeed loading states, error handling, or complex UI
await expressions are great for simple cases

await expressions render nothing until resolved. For loading states, use the full {#await} block.

Syntax Variations

Flexibility for Different Needs

Not every async operation requires the same level of UI feedback. A critical data fetch might need loading spinners, success displays, and detailed error messages—while a background sync might only need to surface failures. Svelte recognizes this reality and provides multiple syntactic forms of the #await block, each optimized for different scenarios.

Understanding when to use each variation is key to writing clean, maintainable code. Let’s explore each form, starting with the most comprehensive and working toward the most minimal.

Full Form: All Three States

The complete #await syntax handles all three promise states explicitly. This is your go-to choice when user experience demands clear feedback at every stage of an async operation.

When to use this form:

  • Fetching data that users are actively waiting for
  • Operations where failures are likely and need clear messaging
  • Any situation where users benefit from knowing “something is happening”
{#await promise}
	<LoadingSpinner />
{:then data}
	<DataDisplay {data} />
{:catch error}
	<ErrorMessage {error} />
{/await}

The structure mirrors a try-catch-finally mental model: show loading while trying, display results on success, handle errors on failure. Each branch is mutually exclusive—only one renders at any time.

Omitting the Catch Block

Sometimes you want to handle errors at a higher level in your component tree rather than inline. Svelte’s <svelte:boundary> component provides this capability, catching errors from any descendant component. When you’re using boundaries, omitting the :catch block keeps your await blocks focused on the happy path.

When to use this form:

  • You have an error boundary wrapping this component
  • The promise genuinely cannot reject (rare, but possible)
  • You want errors to bubble up for centralized handling
{#await promise}
	<p>Computing...</p>
{:then result}
	<p>Result: {result}</p>
{/await}
Omitting the catch block means unhandled rejections

If the promise rejects and you’ve omitted the {:catch} block, the error will propagate to the nearest <svelte:boundary> or remain unhandled. Unhandled rejections can cause confusing behavior, so use this form intentionally, not accidentally.

Omitting the Pending Block

For operations that typically complete near-instantly—like reading from a local cache or performing a quick calculation—a loading state can actually degrade user experience. A spinner that flashes for 50 milliseconds feels janky. In these cases, showing nothing during the brief pending state creates a smoother experience.

When to use this form:

  • Operations expected to complete in under 100ms
  • Cached data that’s almost always available immediately
  • Secondary content where instant feedback isn’t critical
{#await promise then value}
	<p>The answer is {value}</p>
{/await}

This shorthand renders nothing while pending, then shows the content when resolved. Notice the syntax difference: then appears on the same line as #await, and there’s no separate pending block.

When in doubt, show a loading state

If you’re unsure whether an operation is fast enough to skip the loading state, err on the side of showing one. Users tolerate brief spinners better than wondering if something is broken.

Only Catching Errors

Some operations run silently in the background—analytics pings, prefetching, auto-save operations. For these, success is the expected norm and needs no UI acknowledgment. Only failures deserve user attention.

When to use this form:

  • Background sync operations
  • Analytics or logging calls
  • Optimistic UI where success is assumed
  • Prefetching resources for future navigation
{#await promise catch error}
	<p class="error">Something went wrong: {error.message}</p>
{/await}

This renders nothing on success—useful for background operations where success is the silent norm. The UI remains clean when things work, but surfaces problems when they occur.

Here’s a practical example—an auto-save indicator that only appears on failure:

<script>
	let content = $state('')
	let savePromise = $state(Promise.resolve())

	function autoSave() {
		savePromise = fetch('/api/save', {
			method: 'POST',
			body: JSON.stringify({ content })
		})
	}

	// Debounced auto-save on content change
	$effect(() => {
		content // track dependency
		const timer = setTimeout(autoSave, 1000)
		return () => clearTimeout(timer)
	})
</script>

<textarea bind:value={content}></textarea>

{#await savePromise catch error}
	<p class="save-error">Auto-save failed: {error.message}</p>
{/await}

Non-Promise Values

Here’s a behavior that surprises many developers: the #await block doesn’t require a Promise. If you pass a non-Promise value, Svelte skips straight to the :then branch with that value. This happens synchronously, including during server-side rendering.

Why this matters:

  • You can use #await with values that might or might not be async
  • SSR works correctly—non-Promise values render immediately on the server
  • It enables patterns where data sources are interchangeable
<script>
	let data = $state({ name: 'Already loaded' })
</script>

{#await data}
	<p>This never shows because data isn't a Promise</p>
{:then value}
	<p>{value.name}</p>
	<!-- Renders immediately -->
{/await}

This behavior makes {#await} safe to use with values that might or might not be promises—a common scenario when you’re abstracting data sources or working with caches.

Practical example: A data loader that returns cached data synchronously or fetches asynchronously:

<script>
	const cache = new Map()

	function getData(id) {
		if (cache.has(id)) {
			return cache.get(id) // Returns plain object, not Promise
		}
		return fetch(`/api/data/${id}`)
			.then((r) => r.json())
			.then((data) => {
				cache.set(id, data)
				return data
			})
	}

	let itemId = $state(1)
	let dataResult = $derived(getData(itemId))
</script>

{#await dataResult}
	<p>Loading...</p>
{:then data}
	<p>{data.name}</p>
{/await}

When dataResult is a cached value, the loading state never flashes—users see content immediately. When it’s a fetch, the loading state appears appropriately.


Destructuring in Await Blocks

The :then and :catch clauses support destructuring, enabling clean extraction of nested data:

Object Destructuring

<script>
	async function fetchUserWithPosts(id) {
		const [userRes, postsRes] = await Promise.all([
			fetch(`/api/users/${id}`),
			fetch(`/api/users/${id}/posts`)
		])

		return {
			user: await userRes.json(),
			posts: await postsRes.json(),
			fetchedAt: new Date()
		}
	}

	let userId = $state(1)
	let dataPromise = $derived(fetchUserWithPosts(userId))
</script>

{#await dataPromise}
	<p>Loading user and posts...</p>
{:then { user, posts, fetchedAt }}
	<article>
		<header>
			<h1>{user.name}</h1>
			<time>Fetched at {fetchedAt.toLocaleTimeString()}</time>
		</header>
		<section class="posts">
			{#each posts as post}
				<div class="post">
					<h3>{post.title}</h3>
					<p>{post.body}</p>
				</div>
			{/each}
		</section>
	</article>
{:catch { message, status }}
	<div class="error">
		<p>Error {status}: {message}</p>
	</div>
{/await}

Array Destructuring

{#await fetchCoordinates()}
	<p>Getting location...</p>
{:then [latitude, longitude]}
	<p>You are at {latitude}, {longitude}</p>
{:catch error}
	<p>Location unavailable: {error.message}</p>
{/await}

Dynamic Imports with Destructuring

A powerful pattern for code splitting—dynamically import components and destructure the default export:

{#await import('./HeavyComponent.svelte') then { default: HeavyComponent }}
	<HeavyComponent someProp="value" />
{/await}

This delays loading HeavyComponent until the {#await} block renders, reducing your initial bundle size.

Nested Destructuring with Defaults

For APIs with inconsistent response shapes:

{#await apiCall()}
	<p>Loading...</p>
{:then { items = [], meta: { total = 0 } = {} }}
	<p>Found {total} items</p>
	{#each items as item}
		<div>{item.name}</div>
	{/each}
{:catch { status = 500, message = 'Unknown error' }}
	<p>Error {status}: {message}</p>
{/await}

Defaults protect against undefined properties at any nesting level.


Reactive Promises

Automatic Re-fetching

One of the most powerful patterns combines {#await} with $derived to create promises that automatically re-execute when dependencies change:

<script>
	let searchQuery = $state('')
	let category = $state('all')
	let page = $state(1)

	async function searchProducts(query, category, page) {
		const params = new URLSearchParams({
			q: query,
			category: category === 'all' ? '' : category,
			page: String(page),
			limit: '20'
		})

		const response = await fetch(`/api/products?${params}`)
		if (!response.ok) throw new Error('Search failed')
		return response.json()
	}

	// This promise re-creates whenever searchQuery, category, or page changes
	let resultsPromise = $derived(searchProducts(searchQuery, category, page))
</script>

<div class="search-controls">
	<input type="search" placeholder="Search products..." bind:value={searchQuery} />

	<select bind:value={category}>
		<option value="all">All Categories</option>
		<option value="electronics">Electronics</option>
		<option value="clothing">Clothing</option>
		<option value="books">Books</option>
	</select>
</div>

{#await resultsPromise}
	<div class="results-grid loading">
		{#each Array(6) as _}
			<div class="product-skeleton"></div>
		{/each}
	</div>
{:then { products, total, pages }}
	<p class="results-count">{total} products found</p>

	<div class="results-grid">
		{#each products as product (product.id)}
			<div class="product-card">
				<img src={product.image} alt={product.name} />
				<h3>{product.name}</h3>
				<p class="price">${product.price.toFixed(2)}</p>
			</div>
		{/each}
	</div>

	<div class="pagination">
		<button disabled={page === 1} onclick={() => page--}>Previous</button>
		<span>Page {page} of {pages}</span>
		<button disabled={page === pages} onclick={() => page++}>Next</button>
	</div>
{:catch error}
	<div class="error-state">
		<p>Search failed: {error.message}</p>
		<button onclick={() => (searchQuery = searchQuery)}>Retry</button>
	</div>
{/await}

Every time any filter changes, a new search executes automatically. The UI transitions smoothly through loading → results (or error) without manual orchestration.


Race Condition Handling

Using AbortController

A critical challenge with reactive promises: when the user rapidly changes inputs, multiple requests may be in flight simultaneously. If a slower request resolves after a faster one, you’ll display stale data. The solution is AbortController:

<script>
	let searchQuery = $state('')
	let controller = $state(null)

	async function search(query) {
		// Cancel any in-flight request
		if (controller) {
			controller.abort()
		}

		// Create new controller for this request
		controller = new AbortController()
		const { signal } = controller

		try {
			const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal })

			if (!response.ok) throw new Error(`HTTP ${response.status}`)
			return response.json()
		} catch (error) {
			// Don't treat abort as an error
			if (error.name === 'AbortError') {
				// Return a never-resolving promise to prevent state update
				return new Promise(() => {})
			}
			throw error
		}
	}

	let resultsPromise = $derived(search(searchQuery))
</script>

<input type="search" bind:value={searchQuery} placeholder="Search..." />

{#await resultsPromise}
	<p>Searching...</p>
{:then results}
	{#if results.length > 0}
		<ul>
			{#each results as result}
				<li>{result.title}</li>
			{/each}
		</ul>
	{:else}
		<p>No results found</p>
	{/if}
{:catch error}
	<p class="error">{error.message}</p>
{/await}

Encapsulating AbortController in a Reusable Pattern

For cleaner code, create a utility function:

<script>
	let searchQuery = $state('')

	function createAbortableFetcher() {
		let controller = null

		return async function abortableFetch(url, options = {}) {
			// Abort previous request
			controller?.abort()
			controller = new AbortController()

			try {
				const response = await fetch(url, {
					...options,
					signal: controller.signal
				})

				if (!response.ok) {
					throw new Error(`HTTP ${response.status}`)
				}

				return response.json()
			} catch (error) {
				if (error.name === 'AbortError') {
					// Return pending promise to "freeze" the await block
					return new Promise(() => {})
				}
				throw error
			}
		}
	}

	const abortableFetch = createAbortableFetcher()

	let resultsPromise = $derived(
		searchQuery.trim()
			? abortableFetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
			: Promise.resolve([])
	)
</script>

Debouncing Reactive Promises

Rapid user input (like typing in a search box) can trigger excessive API calls. Combine {#await} with debouncing for efficient reactive fetching:

<script>
	let searchInput = $state('')
	let debouncedQuery = $state('')

	// Debounce the search input
	$effect(() => {
		const timer = setTimeout(() => {
			debouncedQuery = searchInput
		}, 300)

		return () => clearTimeout(timer)
	})

	async function search(query) {
		if (!query.trim()) return { results: [], total: 0 }

		const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
		if (!response.ok) throw new Error('Search failed')
		return response.json()
	}

	// Promise only updates when debounced value changes
	let searchPromise = $derived(search(debouncedQuery))
</script>

<input type="search" placeholder="Type to search..." bind:value={searchInput} />

{#if searchInput !== debouncedQuery}
	<p class="typing-indicator">Searching...</p>
{/if}

{#await searchPromise}
	<div class="search-loading">
		<span class="spinner"></span>
	</div>
{:then { results, total }}
	{#if total > 0}
		<ul class="search-results">
			{#each results as result}
				<li>{result.title}</li>
			{/each}
		</ul>
	{:else if debouncedQuery}
		<p class="no-results">No results for "{debouncedQuery}"</p>
	{/if}
{:catch error}
	<p class="error">{error.message}</p>
{/await}

The UI shows immediate feedback (“Searching…”) while debouncing, then transitions to the loading state when the actual fetch begins.


Keeping Previous Data While Loading

(Stale-While-Revalidate)

When updating data based on user input, it’s often better UX to show the previous results while loading new ones, rather than a blank state or spinner. This pattern is known as “stale-while-revalidate”:

<script>
	let category = $state('electronics')
	let previousData = $state(null)

	async function fetchProducts(category) {
		const response = await fetch(`/api/products?category=${category}`)
		if (!response.ok) throw new Error('Failed to fetch')
		const data = await response.json()

		// Store successful results for next time
		previousData = data
		return data
	}

	let productsPromise = $derived(fetchProducts(category))
</script>

<select bind:value={category}>
	<option value="electronics">Electronics</option>
	<option value="clothing">Clothing</option>
	<option value="books">Books</option>
</select>

{#await productsPromise}
	<!-- Show previous data with loading indicator overlay -->
	{#if previousData}
		<div class="products-container loading">
			<div class="loading-overlay">
				<span class="spinner"></span>
				Updating...
			</div>
			<div class="products-grid stale">
				{#each previousData.products as product (product.id)}
					<div class="product-card">{product.name}</div>
				{/each}
			</div>
		</div>
	{:else}
		<div class="skeleton-grid">
			{#each Array(6) as _}
				<div class="product-skeleton"></div>
			{/each}
		</div>
	{/if}
{:then data}
	<div class="products-grid">
		{#each data.products as product (product.id)}
			<div class="product-card">{product.name}</div>
		{/each}
	</div>
{:catch error}
	<p class="error">{error.message}</p>
{/await}

<style>
	.products-container.loading {
		position: relative;
	}

	.loading-overlay {
		position: absolute;
		inset: 0;
		background: rgba(255, 255, 255, 0.8);
		display: flex;
		align-items: center;
		justify-content: center;
		gap: 0.5rem;
		z-index: 10;
	}

	.stale {
		opacity: 0.6;
		pointer-events: none;
	}
</style>

Advanced Cache Implementation

For more sophisticated caching with TTL (time-to-live) and stale-while-revalidate logic, consider this pattern:

<script>
	const cache = new Map()
	const CACHE_TTL = 60000 // 1 minute

	async function fetchWithCache(key, fetcher) {
		const cached = cache.get(key)
		const now = Date.now()

		// Return fresh cached data immediately
		if (cached && now - cached.timestamp < CACHE_TTL) {
			return cached.data
		}

		// Fetch fresh data
		const data = await fetcher()
		cache.set(key, { data, timestamp: now })

		return data
	}

	// For stale-while-revalidate pattern
	function fetchWithSWR(key, fetcher) {
		const cached = cache.get(key)

		const freshPromise = fetcher().then((data) => {
			cache.set(key, { data, timestamp: Date.now() })
			return data
		})

		// If we have any cached data, return it immediately
		if (cached) {
			// Still fetch in background to update cache
			freshPromise.catch(() => {}) // Suppress unhandled rejection
			return cached.data
		}

		return freshPromise
	}

	let userId = $state(1)
	let userPromise = $derived(
		fetchWithSWR(`user-${userId}`, () => fetch(`/api/users/${userId}`).then((r) => r.json()))
	)
</script>

This pattern provides a robust caching mechanism that balances immediacy with freshness, enhancing user experience during data updates.


Nested Await Blocks

Complex applications often require multiple asynchronous operations that depend on each other or can run in parallel. Svelte’s #await blocks can be nested or combined to handle these scenarios cleanly.

Sequential Dependencies

When one async operation depends on the result of another, can’t fetch B until A completes. In this case, nesting #await blocks can handle the sequence:

<script>
	async function fetchUser(id) {
		const res = await fetch(`/api/users/${id}`)
		return res.json()
	}

	async function fetchUserOrders(userId) {
		const res = await fetch(`/api/users/${userId}/orders`)
		return res.json()
	}

	async function fetchOrderDetails(orderId) {
		const res = await fetch(`/api/orders/${orderId}`)
		return res.json()
	}

	let userId = $state(1)
	let selectedOrderId = $state(null)
</script>

{#await fetchUser(userId)}
	<div class="user-loading">Loading user...</div>
{:then user}
	<div class="user-header">
		<h1>{user.name}</h1>
		<p>{user.email}</p>
	</div>

	{#await fetchUserOrders(user.id)}
		<div class="orders-loading">Loading orders...</div>
	{:then orders}
		<div class="orders-list">
			<h2>Orders ({orders.length})</h2>
			{#each orders as order}
				<button
					class="order-item"
					class:selected={selectedOrderId === order.id}
					onclick={() => (selectedOrderId = order.id)}
				>
					Order #{order.id} - ${order.total.toFixed(2)}
				</button>
			{/each}
		</div>

		{#if selectedOrderId}
			{#await fetchOrderDetails(selectedOrderId)}
				<div class="order-details-loading">Loading order details...</div>
			{:then orderDetails}
				<div class="order-details">
					<h3>Order #{orderDetails.id}</h3>
					<ul>
						{#each orderDetails.items as item}
							<li>
								{item.name} × {item.quantity} = ${(item.price * item.quantity).toFixed(2)}
							</li>
						{/each}
					</ul>
					<p class="total">Total: ${orderDetails.total.toFixed(2)}</p>
				</div>
			{:catch error}
				<p class="error">Failed to load order: {error.message}</p>
			{/await}
		{/if}
	{:catch error}
		<p class="error">Failed to load orders: {error.message}</p>
	{/await}
{:catch error}
	<p class="error">Failed to load user: {error.message}</p>
{/await}

Each level waits for its parent before fetching. The UI progressively reveals data as it becomes available.


Parallel Loading with Promise.all

When multiple independent async operations can run simultaneously, use Promise.all to fetch them in parallel. This reduces total loading time compared to sequential fetching.

<script>
	async function fetchDashboardData() {
		const [stats, recentActivity, notifications] = await Promise.all([
			fetch('/api/stats').then((r) => r.json()),
			fetch('/api/activity').then((r) => r.json()),
			fetch('/api/notifications').then((r) => r.json())
		])

		return { stats, recentActivity, notifications }
	}

	let dashboardPromise = $derived(fetchDashboardData())
</script>

{#await dashboardPromise}
	<div class="dashboard-skeleton">
		<div class="skeleton-stats"></div>
		<div class="skeleton-activity"></div>
		<div class="skeleton-notifications"></div>
	</div>
{:then { stats, recentActivity, notifications }}
	<div class="dashboard">
		<section class="stats-panel">
			<div class="stat">
				<span class="value">{stats.totalUsers}</span>
				<span class="label">Users</span>
			</div>
			<div class="stat">
				<span class="value">${stats.revenue.toLocaleString()}</span>
				<span class="label">Revenue</span>
			</div>
			<div class="stat">
				<span class="value">{stats.activeProjects}</span>
				<span class="label">Projects</span>
			</div>
		</section>

		<section class="activity-panel">
			<h2>Recent Activity</h2>
			<ul>
				{#each recentActivity as activity}
					<li>
						<span class="user">{activity.user}</span>
						<span class="action">{activity.action}</span>
						<time>{activity.timestamp}</time>
					</li>
				{/each}
			</ul>
		</section>

		<section class="notifications-panel">
			<h2>Notifications ({notifications.unread})</h2>
			{#each notifications.items as notification}
				<div class="notification" class:unread={!notification.read}>
					<p>{notification.message}</p>
				</div>
			{/each}
		</section>
	</div>
{:catch error}
	<div class="dashboard-error">
		<p>Failed to load dashboard: {error.message}</p>
		<button onclick={() => (dashboardPromise = fetchDashboardData())}> Retry </button>
	</div>
{/await}

All three API calls execute simultaneously, reducing total loading time compared to sequential fetching.

Use Promise.allSettled for Partial Failures

When you want to display whatever succeeds, even if some requests fail:

<script>
	async function fetchWidgetData() {
		const results = await Promise.allSettled([
			fetch('/api/weather').then((r) => r.json()),
			fetch('/api/stocks').then((r) => r.json()),
			fetch('/api/news').then((r) => r.json())
		])

		return {
			weather: results[0].status === 'fulfilled' ? results[0].value : null,
			stocks: results[1].status === 'fulfilled' ? results[1].value : null,
			news: results[2].status === 'fulfilled' ? results[2].value : null,
			errors: results.filter((r) => r.status === 'rejected').map((r) => r.reason)
		}
	}

	let widgetPromise = $derived(fetchWidgetData())
</script>

{#await widgetPromise}
	<p>Loading widgets...</p>
{:then { weather, stocks, news, errors }}
	{#if errors.length > 0}
		<div class="partial-error">Some widgets failed to load</div>
	{/if}

	<div class="widget-grid">
		{#if weather}
			<div class="widget weather">
				<h3>Weather</h3>
				<p>{weather.temperature}°F - {weather.condition}</p>
			</div>
		{:else}
			<div class="widget weather error">
				<p>Weather unavailable</p>
			</div>
		{/if}

		{#if stocks}
			<div class="widget stocks">
				<h3>Stocks</h3>
				{#each stocks.symbols as stock}
					<p>{stock.symbol}: ${stock.price}</p>
				{/each}
			</div>
		{:else}
			<div class="widget stocks error">
				<p>Stocks unavailable</p>
			</div>
		{/if}

		{#if news}
			<div class="widget news">
				<h3>News</h3>
				{#each news.headlines.slice(0, 3) as headline}
					<p>{headline}</p>
				{/each}
			</div>
		{:else}
			<div class="widget news error">
				<p>News unavailable</p>
			</div>
		{/if}
	</div>
{/await}

This pattern provides graceful degradation—users see what’s available rather than nothing.


Independent #await Blocks

Suspense-Like Loading

Sometimes you want multiple independent async operations with their own loading states, rather than waiting for all. This creates a “suspense”-like experience where parts of the UI appear as soon as their data is ready:

<script>
	async function fetchUserProfile() {
		await sleep(800) // Simulate latency
		return fetch('/api/profile').then((r) => r.json())
	}

	async function fetchNotifications() {
		await sleep(400)
		return fetch('/api/notifications').then((r) => r.json())
	}

	async function fetchRecommendations() {
		await sleep(1200)
		return fetch('/api/recommendations').then((r) => r.json())
	}

	function sleep(ms) {
		return new Promise((r) => setTimeout(r, ms))
	}

	let profilePromise = $derived(fetchUserProfile())
	let notificationsPromise = $derived(fetchNotifications())
	let recommendationsPromise = $derived(fetchRecommendations())
</script>

<div class="dashboard-grid">
	<!-- Each section loads independently -->
	<section class="profile-section">
		{#await profilePromise}
			<div class="skeleton profile-skeleton"></div>
		{:then profile}
			<div class="profile-card">
				<img src={profile.avatar} alt={profile.name} />
				<h2>{profile.name}</h2>
				<p>{profile.bio}</p>
			</div>
		{:catch error}
			<p class="error">Failed to load profile</p>
		{/await}
	</section>

	<section class="notifications-section">
		{#await notificationsPromise}
			<div class="skeleton notifications-skeleton"></div>
		{:then notifications}
			<div class="notifications-list">
				<h3>Notifications ({notifications.length})</h3>
				{#each notifications as notification}
					<div class="notification">{notification.message}</div>
				{/each}
			</div>
		{:catch error}
			<p class="error">Failed to load notifications</p>
		{/await}
	</section>

	<section class="recommendations-section">
		{#await recommendationsPromise}
			<div class="skeleton recommendations-skeleton"></div>
		{:then recommendations}
			<div class="recommendations-grid">
				<h3>Recommended for You</h3>
				{#each recommendations as item}
					<div class="recommendation-card">{item.title}</div>
				{/each}
			</div>
		{:catch error}
			<p class="error">Failed to load recommendations</p>
		{/await}
	</section>
</div>

Each section appears as soon as its data is ready, providing a progressive loading experience.


Transitions with Await Blocks

Svelte’s transition directives can be applied to the direct children of #await blocks, allowing for smooth animations between loading, success, and error states. This enhances user experience by providing visual feedback during state changes.

<script>
	import { fade, fly, slide } from 'svelte/transition'

	let query = $state('')
	let debouncedQuery = $state('')

	$effect(() => {
		const timer = setTimeout(() => {
			debouncedQuery = query
		}, 300)
		return () => clearTimeout(timer)
	})

	async function search(q) {
		if (!q) return []
		const res = await fetch(`/api/search?q=${q}`)
		return res.json()
	}

	let searchPromise = $derived(search(debouncedQuery))
</script>

<input bind:value={query} placeholder="Search..." />

{#await searchPromise}
	<div class="loading" transition:fade={{ duration: 150 }}>
		<span class="spinner"></span>
		Searching...
	</div>
{:then results}
	{#if results.length > 0}
		<ul class="results" transition:slide={{ duration: 200 }}>
			{#each results as result, i}
				<li transition:fly={{ y: 10, delay: i * 50 }}>
					{result.title}
				</li>
			{/each}
		</ul>
	{:else if debouncedQuery}
		<p class="no-results" transition:fade>
			No results for "{debouncedQuery}"
		</p>
	{/if}
{:catch error}
	<p class="error" transition:fade>
		{error.message}
	</p>
{/await}
Transition Gotchas

Transitions on the direct children of #await blocks work best when the states have distinct content. For smoother transitions between loading and loaded states, consider using #key:

{#key debouncedQuery}
	{#await searchPromise}
		<div in:fade out:fade>Loading...</div>
	{:then results}
		<div in:fade={{ delay: 150 }}>
			{#each results as result}
				<div>{result.title}</div>
			{/each}
		</div>
	{/await}
{/key}

Timeout Handling

Sometimes, network requests or asynchronous operations can take much longer than expected, or even hang indefinitely. Without proper timeout handling, your UI may remain stuck in a loading state, leaving users frustrated and confused.

It’s important to anticipate these scenarios and provide feedback or fallback behavior when operations exceed a reasonable time limit. this can be achieved by wrapping your promises with timeout logic.

Simple Timeout Wrapper

in this example, we create a withTimeout function that rejects a promise if it doesn’t resolve within a specified time limit. We then use this function to fetch data with a timeout, providing user feedback if the request takes too long.

<script>
	function withTimeout(promise, ms) {
		const timeout = new Promise((_, reject) => {
			setTimeout(() => reject(new Error(`Request timed out after ${ms}ms`)), ms)
		})
		return Promise.race([promise, timeout])
	}

	async function fetchData() {
		const response = await withTimeout(
			fetch('/api/slow-endpoint'),
			5000 // 5 second timeout
		)
		return response.json()
	}

	let dataPromise = $derived(fetchData())
</script>

{#await dataPromise}
	<div class="loading">
		<span class="spinner"></span>
		<p>Loading data...</p>
		<p class="hint">This may take a few seconds</p>
	</div>
{:then data}
	<div class="data">{JSON.stringify(data)}</div>
{:catch error}
	<div class="error">
		{#if error.message.includes('timed out')}
			<p>The request is taking too long. Please try again.</p>
		{:else}
			<p>{error.message}</p>
		{/if}
		<button onclick={() => (dataPromise = fetchData())}>Retry</button>
	</div>
{/await}

Combined Timeout and AbortController

For robust error handling, it’s often necessary to combine both a timeout and an AbortController. This approach allows you to automatically cancel slow requests and free up resources, ensuring your UI remains responsive even when network conditions are poor or the server is unresponsive.

<script>
	function createFetchWithTimeout(timeoutMs = 10000) {
		return async function fetchWithTimeout(url, options = {}) {
			const controller = new AbortController()
			const timeoutId = setTimeout(() => controller.abort(), timeoutMs)

			try {
				const response = await fetch(url, {
					...options,
					signal: controller.signal
				})

				clearTimeout(timeoutId)
				return response
			} catch (error) {
				clearTimeout(timeoutId)

				if (error.name === 'AbortError') {
					throw new Error(`Request timed out after ${timeoutMs}ms`)
				}
				throw error
			}
		}
	}

	const fetchWithTimeout = createFetchWithTimeout(5000)
</script>

Error Handling Strategies

Handling errors gracefully is essential for building resilient and user-friendly applications. In asynchronous workflows, failures can occur for many reasons—network issues, server errors, invalid data, or timeouts. Svelte’s #await block provides flexible ways to surface these errors to users, recover from failures, and maintain a smooth experience. This section explores practical strategies for error handling in real-world scenarios.

1. Inline Error Display

The most straightforward approach is to display errors directly where your data would normally appear. This keeps the user informed about problems in context and allows for simple retry logic.

{#await dataPromise}
	<LoadingSkeleton />
{:then data}
	<DataView {data} />
{:catch error}
	<div class="inline-error">
		<p>{error.message}</p>
		<button onclick={retry}>Try Again</button>
	</div>
{/await}

2. Error Boundaries with <svelte:boundary>

For application-wide error handling, you can use Svelte’s error boundaries. This pattern lets you catch and display errors from any child component or await block, providing a centralized place for error recovery and logging.

<script>
	let criticalDataPromise = $derived(fetchCriticalData())
</script>

<svelte:boundary onerror={(error) => logError(error)}>
	{#snippet failed(error, reset)}
		<div class="boundary-error">
			<h2>Something went wrong</h2>
			<p>{error.message}</p>
			<button onclick={reset}>Reset</button>
		</div>
	{/snippet}

	{#await criticalDataPromise}
		<p>Loading critical data...</p>
	{:then data}
		<CriticalComponent {data} />
	{/await}
	<!-- Errors will bubble up to the boundary if :catch is omitted -->
</svelte:boundary>

Using pending snippet for Initial Loading

Boundaries can also show a loading state while await expressions first resolve:

<svelte:boundary>
	{#snippet pending()}
		<div class="loading-overlay">
			<span class="spinner"></span>
			<p>Loading application...</p>
		</div>
	{/snippet}

	{#snippet failed(error, reset)}
		<p>Error: {error.message} <button onclick={reset}>Retry</button></p>
	{/snippet}

	<Dashboard />
</svelte:boundary>

Tracking Pending Count with $effect.pending()

For more granular control, use $effect.pending() to know how many promises are pending within a boundary:

<svelte:boundary>
	{#snippet pending()}
		<p>Loading {$effect.pending()} item(s)...</p>
	{/snippet}

	<UserProfile />
	<Notifications />
	<Recommendations />
</svelte:boundary>

The pending snippet only shows during initial load. For subsequent async updates (like re-fetching data), use $effect.pending() to show inline loading indicators.

3. Retry with Exponential Backoff and Visual Feedback

Sometimes, network errors are temporary. You can improve reliability by automatically retrying failed requests with exponential backoff, and showing users which attempt is in progress. This pattern helps recover from flaky connections and gives users confidence that the app is trying to resolve issues.

<script>
	let retryState = $state({ attempt: 0, maxAttempts: 3, isRetrying: false })

	async function fetchWithRetry(url) {
		retryState = { attempt: 0, maxAttempts: 3, isRetrying: false }
		let lastError

		for (let attempt = 1; attempt <= retryState.maxAttempts; attempt++) {
			try {
				retryState = { ...retryState, attempt, isRetrying: attempt > 1 }

				const response = await fetch(url)
				if (!response.ok) throw new Error(`HTTP ${response.status}`)

				retryState = { ...retryState, isRetrying: false }
				return response.json()
			} catch (error) {
				lastError = error

				if (attempt < retryState.maxAttempts) {
					// Exponential backoff: 1s, 2s, 4s
					const delay = Math.pow(2, attempt - 1) * 1000
					await new Promise((r) => setTimeout(r, delay))
				}
			}
		}

		retryState = { ...retryState, isRetrying: false }
		throw lastError
	}

	let dataPromise = $derived(fetchWithRetry('/api/flaky-endpoint'))
</script>

{#await dataPromise}
	<div class="loading">
		{#if retryState.isRetrying}
			<p class="retry-notice">
				Retry attempt {retryState.attempt} of {retryState.maxAttempts}...
			</p>
		{:else}
			<p>Loading...</p>
		{/if}
		<span class="spinner"></span>
	</div>
{:then data}
	<div class="data">{JSON.stringify(data)}</div>
{:catch error}
	<div class="error">
		<p>Failed after {retryState.maxAttempts} attempts: {error.message}</p>
		<button onclick={() => (dataPromise = fetchWithRetry('/api/flaky-endpoint'))}>
			Try Again
		</button>
	</div>
{/await}

<style>
	.retry-notice {
		color: #f59e0b;
		font-weight: 500;
	}
</style>

4. Typed Error Objects

For advanced error handling, you can create custom error classes that include additional information such as status codes and error types. This allows you to distinguish between different error scenarios and display tailored UI for each case, improving both developer experience and user feedback.

<script>
	class ApiError extends Error {
		constructor(message, status, code) {
			super(message)
			this.status = status
			this.code = code
			this.name = 'ApiError'
		}
	}

	async function fetchData() {
		const response = await fetch('/api/data')

		if (!response.ok) {
			const body = await response.json().catch(() => ({}))
			throw new ApiError(body.message || 'Request failed', response.status, body.code || 'UNKNOWN')
		}

		return response.json()
	}

	let dataPromise = $derived(fetchData())
</script>

{#await dataPromise}
	<p>Loading...</p>
{:then data}
	<DataDisplay {data} />
{:catch error}
	{#if error instanceof ApiError}
		{#if error.status === 404}
			<NotFound message={error.message} />
		{:else if error.status === 401}
			<LoginPrompt />
		{:else if error.status === 403}
			<AccessDenied />
		{:else if error.status >= 500}
			<ServerError code={error.code} />
		{:else}
			<GenericError message={error.message} />
		{/if}
	{:else if error.name === 'TypeError' || error.message.includes('network')}
		<NetworkError />
	{:else}
		<UnexpectedError message={error.message} />
	{/if}
{/await}

Using @const Inside Await Blocks

The @const directive lets you define computed values directly inside your await block branches. This keeps your templates clean and makes it easy to derive summary statistics, flags, or other helpers for rendering complex UI states without cluttering your script section.

{#await fetchOrdersPromise}
	<p>Loading orders...</p>
{:then orders}
	{@const totalValue = orders.reduce((sum, o) => sum + o.total, 0)}
	{@const pendingOrders = orders.filter((o) => o.status === 'pending')}
	{@const averageOrderValue = orders.length > 0 ? totalValue / orders.length : 0}

	<div class="orders-summary">
		<p>Total Orders: {orders.length}</p>
		<p>Total Value: ${totalValue.toFixed(2)}</p>
		<p>Average Order: ${averageOrderValue.toFixed(2)}</p>
		<p>Pending: {pendingOrders.length}</p>
	</div>

	{#each orders as order}
		{@const isHighValue = order.total > averageOrderValue * 1.5}
		<div class="order" class:high-value={isHighValue}>
			<span>Order #{order.id}</span>
			<span>${order.total.toFixed(2)}</span>
			{#if isHighValue}
				<span class="badge">High Value</span>
			{/if}
		</div>
	{/each}
{:catch error}
	{@const isNetworkError = error.message.includes('network')}
	{@const isServerError = error.status >= 500}

	<div class="error-panel">
		{#if isNetworkError}
			<p>Please check your internet connection.</p>
		{:else if isServerError}
			<p>Our servers are having issues. Please try again later.</p>
		{:else}
			<p>{error.message}</p>
		{/if}
	</div>
{/await}

Infinite Scroll Pattern

Infinite scroll is a popular UX pattern for loading more data as the user scrolls, without requiring manual pagination. With Svelte’s #await block, you can manage loading states and errors for each batch of data, providing a seamless experience as new items are fetched and appended to the list.

<script>
	let items = $state([])
	let page = $state(1)
	let hasMore = $state(true)
	let loadingMore = $state(false)
	let initialLoadPromise = $state(null)

	async function fetchPage(pageNum) {
		const response = await fetch(`/api/items?page=${pageNum}&limit=20`)
		if (!response.ok) throw new Error('Failed to fetch')
		return response.json()
	}

	async function loadInitial() {
		const data = await fetchPage(1)
		items = data.items
		hasMore = data.hasMore
		page = 1
		return data
	}

	async function loadMore() {
		if (loadingMore || !hasMore) return

		loadingMore = true
		try {
			const nextPage = page + 1
			const data = await fetchPage(nextPage)

			items = [...items, ...data.items]
			hasMore = data.hasMore
			page = nextPage
		} finally {
			loadingMore = false
		}
	}

	function handleScroll(event) {
		const { scrollTop, scrollHeight, clientHeight } = event.target
		const nearBottom = scrollHeight - scrollTop - clientHeight < 200

		if (nearBottom && hasMore && !loadingMore) {
			loadMore()
		}
	}

	// Initial load
	initialLoadPromise = loadInitial()
</script>

{#await initialLoadPromise}
	<div class="initial-loading">
		{#each Array(5) as _}
			<div class="item-skeleton"></div>
		{/each}
	</div>
{:then}
	<div class="items-container" onscroll={handleScroll}>
		{#each items as item (item.id)}
			<div class="item-card">
				<h3>{item.title}</h3>
				<p>{item.description}</p>
			</div>
		{/each}

		{#if loadingMore}
			<div class="loading-more">
				<span class="spinner"></span>
				Loading more...
			</div>
		{/if}

		{#if !hasMore && items.length > 0}
			<p class="end-message">You've reached the end!</p>
		{/if}
	</div>
{:catch error}
	<div class="error">
		<p>Failed to load items: {error.message}</p>
		<button onclick={() => (initialLoadPromise = loadInitial())}>Retry</button>
	</div>
{/await}

<style>
	.items-container {
		height: 600px;
		overflow-y: auto;
	}

	.loading-more {
		display: flex;
		align-items: center;
		justify-content: center;
		gap: 0.5rem;
		padding: 1rem;
	}

	.end-message {
		text-align: center;
		color: #6b7280;
		padding: 1rem;
	}
</style>

Optimistic UI Updates

Optimistic UI updates make your app feel faster by immediately reflecting user actions in the interface, even before the server responds. This technique improves perceived performance and user satisfaction, but requires careful handling of errors and rollbacks if the server rejects the change.

<script>
	let todos = $state([
		{ id: 1, text: 'Learn Svelte', completed: false },
		{ id: 2, text: 'Build an app', completed: false }
	])

	let pendingOperations = $state(new Map()) // id -> operation type

	async function toggleTodo(id) {
		const todo = todos.find((t) => t.id === id)
		const previousState = todo.completed

		// Optimistic update
		todo.completed = !todo.completed
		pendingOperations.set(id, 'toggle')
		pendingOperations = new Map(pendingOperations) // Trigger reactivity

		try {
			const response = await fetch(`/api/todos/${id}`, {
				method: 'PATCH',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ completed: todo.completed })
			})

			if (!response.ok) throw new Error('Failed to update')
		} catch (error) {
			// Revert on failure
			todo.completed = previousState
			alert('Failed to update todo. Please try again.')
		} finally {
			pendingOperations.delete(id)
			pendingOperations = new Map(pendingOperations)
		}
	}

	async function deleteTodo(id) {
		const todoIndex = todos.findIndex((t) => t.id === id)
		const deletedTodo = todos[todoIndex]

		// Optimistic update
		todos = todos.filter((t) => t.id !== id)
		pendingOperations.set(id, 'delete')
		pendingOperations = new Map(pendingOperations)

		try {
			const response = await fetch(`/api/todos/${id}`, { method: 'DELETE' })
			if (!response.ok) throw new Error('Failed to delete')
		} catch (error) {
			// Revert on failure
			todos = [...todos.slice(0, todoIndex), deletedTodo, ...todos.slice(todoIndex)]
			alert('Failed to delete todo. Please try again.')
		} finally {
			pendingOperations.delete(id)
			pendingOperations = new Map(pendingOperations)
		}
	}
</script>

<ul class="todo-list">
	{#each todos as todo (todo.id)}
		{@const isPending = pendingOperations.has(todo.id)}
		{@const operation = pendingOperations.get(todo.id)}

		<li class:completed={todo.completed} class:pending={isPending}>
			<label>
				<input
					type="checkbox"
					checked={todo.completed}
					onchange={() => toggleTodo(todo.id)}
					disabled={isPending}
				/>
				<span>{todo.text}</span>
			</label>

			<div class="actions">
				{#if isPending}
					<span class="sync-indicator">
						{operation === 'toggle' ? 'Saving...' : 'Deleting...'}
					</span>
				{:else}
					<button onclick={() => deleteTodo(todo.id)}>Delete</button>
				{/if}
			</div>
		</li>
	{/each}
</ul>

<style>
	.pending {
		opacity: 0.6;
		pointer-events: none;
	}

	.completed span {
		text-decoration: line-through;
		color: #6b7280;
	}

	.sync-indicator {
		font-size: 0.75rem;
		color: #6b7280;
		font-style: italic;
	}
</style>

Form Submissions with #await Blocks

Form submissions are a common source of asynchronous UI complexity. By using Svelte’s #await block, you can manage loading, success, and error states declaratively, providing clear feedback to users and keeping your form logic simple and maintainable.

<script>
	let formData = $state({ email: '', message: '' })
	let submitState = $state('idle') // 'idle' | 'submitting' | 'success' | 'error'
	let submitPromise = $state(null)
	let submitError = $state(null)

	async function handleSubmit(event) {
		event.preventDefault()
		submitState = 'submitting'
		submitError = null

		submitPromise = fetch('/api/contact', {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify(formData)
		}).then(async (response) => {
			if (!response.ok) {
				const error = await response.json()
				throw new Error(error.message || 'Submission failed')
			}
			return response.json()
		})

		try {
			await submitPromise
			submitState = 'success'
		} catch (error) {
			submitState = 'error'
			submitError = error
		}
	}

	function resetForm() {
		formData = { email: '', message: '' }
		submitState = 'idle'
		submitPromise = null
		submitError = null
	}
</script>

{#if submitState === 'idle' || submitState === 'error'}
	<form onsubmit={handleSubmit}>
		{#if submitError}
			<div class="form-error">
				{submitError.message}
			</div>
		{/if}

		<label>
			Email:
			<input type="email" bind:value={formData.email} required />
		</label>

		<label>
			Message:
			<textarea bind:value={formData.message} required></textarea>
		</label>

		<button type="submit">Send Message</button>
	</form>
{:else if submitState === 'submitting'}
	<div class="submitting">
		<span class="spinner"></span>
		<p>Sending your message...</p>
	</div>
{:else if submitState === 'success'}
	<div class="success">
		<span class="icon"></span>
		<h2>Message Sent!</h2>
		<p>We'll get back to you soon.</p>
		<button onclick={resetForm}>Send Another</button>
	</div>
{/if}

Pagination Pattern

Pagination is a classic pattern for displaying large datasets in manageable chunks. With Svelte’s #await block, you can handle loading states and errors for each page, making navigation between pages smooth and user-friendly.

<script>
	let currentPage = $state(1)
	let pageSize = $state(10)

	async function fetchPage(page, size) {
		const response = await fetch(`/api/items?page=${page}&limit=${size}`)
		if (!response.ok) throw new Error('Failed to fetch')
		return response.json()
	}

	let pagePromise = $derived(fetchPage(currentPage, pageSize))
</script>

{#await pagePromise}
	<div class="table-loading">
		{#each Array(pageSize) as _}
			<div class="row-skeleton"></div>
		{/each}
	</div>
{:then { items, total, totalPages }}
	<table>
		<thead>
			<tr>
				<th>ID</th>
				<th>Name</th>
				<th>Status</th>
			</tr>
		</thead>
		<tbody>
			{#each items as item (item.id)}
				<tr>
					<td>{item.id}</td>
					<td>{item.name}</td>
					<td>{item.status}</td>
				</tr>
			{/each}
		</tbody>
	</table>

	<div class="pagination">
		<button disabled={currentPage === 1} onclick={() => currentPage--}> Previous </button>

		<span>Page {currentPage} of {totalPages} ({total} total items)</span>

		<button disabled={currentPage === totalPages} onclick={() => currentPage++}> Next </button>
	</div>
{:catch error}
	<div class="table-error">
		<p>Failed to load data: {error.message}</p>
		<button onclick={() => (currentPage = currentPage)}>Retry</button>
	</div>
{/await}

Accessibility Considerations

Accessibility is crucial for making your async UI usable by everyone, including people using screen readers or other assistive technologies. By adding proper ARIA roles and live regions, you can ensure that loading states, errors, and content updates are announced clearly and promptly to all users.

<script>
	let dataPromise = $derived(fetchData())
</script>

{#await dataPromise}
	<!-- Announce loading state to screen readers -->
	<div role="status" aria-live="polite" aria-busy="true" class="loading">
		<span class="spinner" aria-hidden="true"></span>
		<span class="sr-only">Loading content, please wait...</span>
		<span aria-hidden="true">Loading...</span>
	</div>
{:then data}
	<!-- Announce when content is ready -->
	<div role="region" aria-live="polite" aria-busy="false" aria-label="Content loaded">
		<DataDisplay {data} />
	</div>
{:catch error}
	<!-- Announce errors clearly -->
	<div role="alert" aria-live="assertive" class="error">
		<span class="error-icon" aria-hidden="true">⚠️</span>
		<p>Error: {error.message}</p>
		<button onclick={retry}> Retry loading </button>
	</div>
{/await}

<style>
	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		padding: 0;
		margin: -1px;
		overflow: hidden;
		clip: rect(0, 0, 0, 0);
		white-space: nowrap;
		border: 0;
	}
</style>

Key accessibility practices:

  1. Use role="status" for loading states so screen readers announce them
  2. Use aria-live="polite" for non-critical updates
  3. Use role="alert" and aria-live="assertive" for errors
  4. Provide screen reader-only text with .sr-only class
  5. Use aria-busy to indicate loading state
  6. Ensure focus management after state changes

Loading State Timing

when dealing with asynchronous operations, the timing of loading indicators can significantly impact user experience. Poorly timed spinners or loading messages can lead to a jarring interface, making the application feel slow or unresponsive. This section explores strategies for managing loading state timing effectively.

Avoiding UI Flicker

For fast operations, a loading spinner that flashes briefly is worse than no spinner at all. Implement minimum display times:

<script>
	async function withMinimumDelay(promise, minMs = 400) {
		const startTime = Date.now()
		const result = await promise
		const elapsed = Date.now() - startTime

		if (elapsed < minMs) {
			await new Promise((r) => setTimeout(r, minMs - elapsed))
		}

		return result
	}

	// For operations that might be very fast
	let dataPromise = $derived(withMinimumDelay(fetchData(), 400))
</script>

{#await dataPromise}
	<LoadingSpinner />
{:then data}
	<DataDisplay {data} />
{/await}

Delayed Loading Indicator

For typically fast operations, only show the loading state if it takes longer than expected:

<script>
	let showLoading = $state(false)
	let loadingTimer = null

	async function fetchWithDelayedLoader() {
		showLoading = false

		// Only show loading after 200ms
		loadingTimer = setTimeout(() => {
			showLoading = true
		}, 200)

		try {
			const response = await fetch('/api/data')
			return response.json()
		} finally {
			clearTimeout(loadingTimer)
		}
	}

	let dataPromise = $derived(fetchWithDelayedLoader())
</script>

{#await dataPromise}
	{#if showLoading}
		<LoadingSpinner />
	{/if}
{:then data}
	<DataDisplay {data} />
{:catch error}
	<ErrorDisplay {error} />
{/await}

Common Pitfalls and Solutions

When working with Svelte’s #await blocks, developers may encounter several common pitfalls that can lead to suboptimal performance, confusing UI states, or even bugs. This section highlights some of these issues and provides practical solutions to avoid them.

1. Creating New Promises on Every Render

Pitfall: Creating a new promise inside the await block on every render causes unnecessary network requests and breaks reactivity.

Solution: Store the promise in a variable or derived state so it only updates when needed.

<!-- PREFERRED: Store the promise in a variable -->
<script>
	let dataPromise = $derived(fetch('/api/data').then((r) => r.json()))
</script>

<!-- AVOID: This creates a new promise every render cycle -->
{#await fetch('/api/data').then((r) => r.json())}
	<p>Loading...</p>
{:then data}
	<p>{data}</p>
{/await}

{#await dataPromise}
	<p>Loading...</p>
{:then data}
	<p>{data}</p>
{/await}

2. Forgetting Error Handling

Pitfall: Omitting error handling in await blocks can lead to unhandled promise rejections and confusing UI for users.

Solution: Always include a {:catch} block to gracefully handle errors and inform the user.

<!-- AVOID: Unhandled rejection if promise fails -->
{#await riskyOperation() then result}
	<p>{result}</p>
{/await}

<!-- PREFERRED: Always handle potential errors -->
{#await riskyOperation()}
	<p>Loading...</p>
{:then result}
	<p>{result}</p>
{:catch error}
	<p>Error: {error.message}</p>
{/await}

3. Race Conditions with Rapid Updates

Pitfall: When multiple async requests are triggered rapidly (e.g., user typing in a search box), slower requests may resolve after faster ones, showing stale data.

Solution: Use AbortController to cancel previous requests and ensure only the latest result is shown.

<!-- AVOID: Potential race condition -->
<script>
	let query = $state('')

	// Slow request might resolve after a faster one
	let resultsPromise = $derived(fetch(`/api/search?q=${query}`).then((r) => r.json()))
</script>
<!-- PREFERRED: Use AbortController for cancellation -->
<script>
	let query = $state('')
	let controller = null

	async function search(q) {
		// Cancel previous request
		controller?.abort()
		controller = new AbortController()

		try {
			const response = await fetch(`/api/search?q=${q}`, {
				signal: controller.signal
			})
			return response.json()
		} catch (error) {
			if (error.name === 'AbortError') {
				return new Promise(() => {}) // Never resolves
			}
			throw error
		}
	}

	let resultsPromise = $derived(search(query))
</script>

4. Not Showing Loading State for Quick Operations

Pitfall: Fast operations may cause the loading spinner to flash briefly, resulting in a jumpy UI.

Solution: Add a minimum display time for loading indicators to ensure a smooth user experience.

<!-- PREFERRED: Add minimum display time -->
<script>
	async function withMinimumDelay(promise, minMs = 300) {
		const [result] = await Promise.all([promise, new Promise((r) => setTimeout(r, minMs))])
		return result
	}

	let dataPromise = $derived(withMinimumDelay(quickOperation()))
</script>

<!-- AVOID: Jumpy UI for fast operations -->
{#await quickOperation()}
	<FullPageSpinner /> <!-- Flashes briefly -->
{:then result}
	<p>{result}</p>
{/await}

5. Memory Leaks with Component Unmounting

Pitfall: Async operations may try to update state after a component is unmounted, causing memory leaks or errors.

Solution: Use $effect with a cleanup function to cancel requests when the component unmounts or dependencies change. This is the modern Svelte 5 approach.

<script>
	let userId = $state(1)
	let userData = $state(null)
	let error = $state(null)

	// $effect automatically handles cleanup when:
	// - the component is destroyed
	// - userId changes (before re-running)
	$effect(() => {
		const controller = new AbortController()

		async function loadUser() {
			try {
				const response = await fetch(`/api/users/${userId}`, {
					signal: controller.signal
				})
				userData = await response.json()
			} catch (err) {
				// Ignore abort errors - they're expected during cleanup
				if (err.name !== 'AbortError') {
					error = err
				}
			}
		}

		loadUser()

		// Cleanup function runs before effect re-runs or on unmount
		return () => {
			controller.abort()
		}
	})
</script>

{#if error}
	<p class="error">{error.message}</p>
{:else if userData}
	<p>{userData.name}</p>
{:else}
	<p>Loading...</p>
{/if}

For simpler cases with $derived promises, the cleanup is automatic—when a new promise is created, Svelte stops tracking the old one:

<script>
	let userId = $state(1)

	// When userId changes, Svelte automatically ignores
	// the result of the previous promise
	let userPromise = $derived(fetch(`/api/users/${userId}`).then((r) => r.json()))
</script>

{#await userPromise}
	<p>Loading...</p>
{:then user}
	<p>{user.name}</p>
{:catch error}
	<p>{error.message}</p>
{/await}

6. Not Preserving Scroll Position

Pitfall: When navigating or updating content, users may lose their scroll position, leading to a frustrating experience.

Solution: Use the @attach directive (recommended for DOM interactions) to restore and save scroll position, ensuring smooth navigation and continuity for users.

<script>
	let scrollPosition = $state(0)

	// Attachment runs when element mounts - perfect for DOM setup
	function restoreScroll(element) {
		// Restore scroll position on mount
		if (scrollPosition > 0) {
			element.scrollTop = scrollPosition
		}

		// Save scroll position on scroll
		function handleScroll() {
			scrollPosition = element.scrollTop
		}

		element.addEventListener('scroll', handleScroll)

		// Cleanup when element unmounts
		return () => {
			element.removeEventListener('scroll', handleScroll)
		}
	}
</script>

{#await dataPromise}
	<div class="loading">Loading...</div>
{:then data}
	<!-- {@attach} is ideal here: runs on mount, has direct element access -->
	<div class="scrollable-content" {@attach restoreScroll}>
		{#each data.items as item}
			<div>{item.name}</div>
		{/each}
	</div>
{/await}

Alternative using $effect (when you need reactive dependencies):

<script>
	let scrollPosition = $state(0)
	let container = $state(null)

	function saveScroll() {
		if (container) {
			scrollPosition = container.scrollTop
		}
	}

	// $effect is better when scroll restoration depends on other reactive state
	// e.g., if scrollPosition came from a store or needed to react to route changes
	$effect(() => {
		if (container && scrollPosition > 0) {
			container.scrollTop = scrollPosition
		}
	})
</script>

{#await dataPromise}
	<div class="loading">Loading...</div>
{:then data}
	<div bind:this={container} class="scrollable-content" onscroll={saveScroll}>
		{#each data.items as item}
			<div>{item.name}</div>
		{/each}
	</div>
{/await}

When to use which:

  • {@attach}: Best for self-contained DOM interactions where the element is the primary concern
  • $effect: Better when the behavior depends on multiple reactive values or external state

Integration with SvelteKit Load Functions

In SvelteKit applications, data loading typically happens in +page.js load functions. The #await block complements this for client-side operations:

<!-- +page.svelte -->
<script>
	let { data } = $props() // Server-loaded data

	// Additional client-side async operations
	let recommendationsPromise = $derived(
		fetch(`/api/recommendations?based_on=${data.product.id}`).then((r) => r.json())
	)
</script>

<!-- Server data is immediately available -->
<h1>{data.product.name}</h1>
<p>{data.product.description}</p>
<p>${data.product.price}</p>

<!-- Client-side enhancement -->
<section class="recommendations">
	<h2>You Might Also Like</h2>

	{#await recommendationsPromise}
		<div class="recommendations-skeleton">
			{#each Array(4) as _}
				<div class="product-skeleton"></div>
			{/each}
		</div>
	{:then recommendations}
		<div class="recommendations-grid">
			{#each recommendations as product}
				<a href="/products/{product.slug}" class="product-card">
					<img src={product.image} alt={product.name} />
					<h3>{product.name}</h3>
					<p>${product.price}</p>
				</a>
			{/each}
		</div>
	{:catch}
		<!-- Silently fail - recommendations are nice-to-have -->
	{/await}
</section>

TypeScript Integration

For type-safe async operations:

<script lang="ts">
	interface User {
		id: number
		name: string
		email: string
	}

	interface ApiError {
		message: string
		code: string
		status: number
	}

	async function fetchUser(id: number): Promise<User> {
		const response = await fetch(`/api/users/${id}`)
		if (!response.ok) {
			const error: ApiError = await response.json()
			throw error
		}
		return response.json()
	}

	let userId = $state(1)
	let userPromise: Promise<User> = $derived(fetchUser(userId))
</script>

{#await userPromise}
	<p>Loading...</p>
{:then user}
	<!-- user is typed as User -->
	<p>{user.name} - {user.email}</p>
{:catch error}
	<!-- error is typed as unknown by default -->
	{#if typeof error === 'object' && error !== null && 'message' in error}
		<p>{(error as ApiError).message}</p>
	{:else}
		<p>Unknown error occurred</p>
	{/if}
{/await}

Type-Safe Error Handling

<script lang="ts">
	function isApiError(error: unknown): error is ApiError {
		return typeof error === 'object' && error !== null && 'status' in error && 'message' in error
	}
</script>

{#await dataPromise}
	<Loading />
{:then data}
	<DataDisplay {data} />
{:catch error}
	{#if isApiError(error)}
		<ApiErrorDisplay status={error.status} message={error.message} />
	{:else if error instanceof Error}
		<GenericError message={error.message} />
	{:else}
		<UnknownError />
	{/if}
{/await}

Best Practices Summary

  1. Always handle errors — Use the :catch block or wrap in a <svelte:boundary>
  2. Store promises in variables — Avoid creating new promises on each render
  3. Use $derived for reactive promises — Automatic re-fetching when dependencies change
  4. Debounce rapid inputs — Prevent excessive API calls
  5. Use AbortController — Cancel stale requests to prevent race conditions
  6. Show meaningful loading states — Skeleton screens are better than spinners
  7. Consider partial failures — Use Promise.allSettled when appropriate
  8. Optimize for perceived performance — Consider optimistic updates
  9. Keep previous data visible — Use stale-while-revalidate patterns
  10. Handle timeouts — Don’t let users wait forever
  11. Make it accessible — Use proper ARIA attributes and announcements
  12. Type your async functions — TypeScript improves maintainability
  13. Use {@const} for computed values — Keep templates clean
  14. Manage loading indicator timing — Avoid flicker for fast operations
  15. Use $effect cleanup for cancellation — Modern pattern for preventing memory leaks
  16. Leverage $effect.pending() — Track pending promises for granular loading UI

Quick Reference

<!-- Reactive with $derived -->
<script>
	let id = $state(1)
	let dataPromise = $derived(fetchData(id))
</script>

<!-- Full syntax -->
{#await promise}
	<!-- pending -->
{:then value}
	<!-- fulfilled -->
{:catch error}
	<!-- rejected -->
{/await}

<!-- Omit catch -->
{#await promise}
	<!-- pending -->
{:then value}
	<!-- fulfilled -->
{/await}

<!-- Omit pending -->
{#await promise then value}
	<!-- fulfilled -->
{/await}

<!-- Only catch -->
{#await promise catch error}
	<!-- rejected -->
{/await}

<!-- With destructuring -->
{#await promise then { data, meta }}
	<p>{data.title}</p>
{/await}

<!-- Array destructuring -->
{#await promise then [first, second]}
	<p>{first} and {second}</p>
{/await}

<!-- Dynamic import -->
{#await import('./Component.svelte') then { default: Component }}
	<Component />
{/await}

<!-- With @const -->
{#await promise then items}
	{@const total = items.reduce((a, b) => a + b.value, 0)}
	<p>Total: {total}</p>
{/await}

<!-- Track pending count with $effect.pending() -->
{#if $effect.pending()}
	<p>Loading {$effect.pending()} item(s)...</p>
{/if}

Conclusion

The #await block represents Svelte’s declarative answer to the ubiquitous challenge of async state management in modern web applications. By transforming the imperative pattern of promise handling—with its manual loading flags, error state tracking, and cleanup concerns—into a declarative template structure, Svelte enables you to express complex async workflows with remarkable clarity.

The integration with $effect.pending() for tracking multiple promises and support for destructuring resolved values showcases how Svelte 5 builds on this foundation to handle increasingly sophisticated async patterns.

Mastering #await requires understanding its various forms: the full three-branch syntax for explicit loading/success/error states, the shorthand forms for common patterns, and the integration with SvelteKit’s server load functions for SSR-compatible async data.

The key is recognizing when declarative template-level async handling improves code clarity versus when programmatic $effect-based approaches better fit your architecture. By combining #await with proper error boundaries, loading state tracking, and reactive derived state, you can build resilient, user-friendly interfaces that gracefully handle the asynchronous reality of modern web development.

Key Takeaways

  • #await blocks declaratively handle promise states with three branches: {#await} for pending, {:then} for fulfilled, and {:catch} for rejected promises
  • Shorthand syntax omits unused branches - {#await promise then value} skips pending UI, and {#await promise catch error} handles only errors
  • Destructuring extracts resolved values directly: {#await promise then { data, meta }} or {#await promise then [first, second]} for arrays
  • Promises are tracked automatically - when the promise reference changes, #await resets to pending state and awaits the new promise
  • $effect.pending() counts active promises globally, enabling “loading N items” indicators and application-wide loading state tracking
  • SvelteKit integration via data prop - load functions return promises resolved server-side during SSR and streamed to client for hydration
  • Error handling requires explicit {:catch} blocks or parent error boundaries - unhandled rejections don’t automatically display user-friendly error UI
  • Component-level promise management with $state for programmatic control, $derived for computed promises, and $effect for side effects on resolution

See Also