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.
- Pending: The operation is in progress
- Fulfilled: The operation completed successfully with a value
- 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
| Pattern | Use Case |
|---|---|
{await expr} | Simple display, no loading/error UI needed |
{#await} block | Need loading states, error handling, or complex UI |
await expressions are great for simple cases
awaitexpressions 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 rejectionsIf 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 stateIf 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
#awaitwith 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 GotchasTransitions on the direct children of
#awaitblocks 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:
- Use
role="status"for loading states so screen readers announce them - Use
aria-live="polite"for non-critical updates - Use
role="alert"andaria-live="assertive"for errors - Provide screen reader-only text with
.sr-onlyclass - Use
aria-busyto indicate loading state - 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
- Always handle errors — Use the
:catchblock or wrap in a<svelte:boundary> - Store promises in variables — Avoid creating new promises on each render
- Use
$derivedfor reactive promises — Automatic re-fetching when dependencies change - Debounce rapid inputs — Prevent excessive API calls
- Use AbortController — Cancel stale requests to prevent race conditions
- Show meaningful loading states — Skeleton screens are better than spinners
- Consider partial failures — Use
Promise.allSettledwhen appropriate - Optimize for perceived performance — Consider optimistic updates
- Keep previous data visible — Use stale-while-revalidate patterns
- Handle timeouts — Don’t let users wait forever
- Make it accessible — Use proper ARIA attributes and announcements
- Type your async functions — TypeScript improves maintainability
- Use
{@const}for computed values — Keep templates clean - Manage loading indicator timing — Avoid flicker for fast operations
- Use
$effectcleanup for cancellation — Modern pattern for preventing memory leaks - 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
#awaitblocks 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,
#awaitresets 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
dataprop -loadfunctions 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
$statefor programmatic control,$derivedfor computed promises, and$effectfor side effects on resolution
See Also
- Official Svelte 5 Documentation -
{#await} - MDN Web Docs - Promises
- SvelteKit Loading Data - Server-side data loading with
loadfunctions $effect.pending()- Tracking multiple pending promises$state- Reactive state for promise management$derived- Computed promises from reactive dependencies- JavaScript async/await - Understanding async patterns