Building Async Components in Svelte 5: Complete Guide

Svelte 5 introduces a groundbreaking feature that changes how we handle asynchronous operations in components. With the new await
expressions, you can now use the await
keyword directly in your component scripts, derived expressions, and even in your markup. This eliminates much of the boilerplate code traditionally required for async operations while maintaining Svelte's signature simplicity.
This tutorial will walk you through implementing async components from basic setup to advanced patterns, showing you how to leverage this powerful new feature in real-world scenarios.
Link to section: Prerequisites and SetupPrerequisites and Setup
Before diving into async components, ensure you have Svelte 5.36 or later installed. The async feature is currently experimental, requiring an explicit opt-in configuration.
First, check your current Svelte version:
npm list svelte
If you need to upgrade to Svelte 5.36 or later:
npm install svelte@latest
For new projects, create a SvelteKit project with Svelte 5:
npm create svelte@latest async-components-demo
cd async-components-demo
npm install
Link to section: Enabling Experimental Async SupportEnabling Experimental Async Support
The async feature requires enabling an experimental flag in your svelte.config.js
file. Open the configuration file and add the experimental option:
// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
export default {
kit: {
adapter: adapter()
},
compilerOptions: {
experimental: {
async: true
}
}
};
If you're using Vite directly without SvelteKit, configure the experimental flag in your Vite config:
// vite.config.js
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [
svelte({
compilerOptions: {
experimental: {
async: true
}
}
})
]
});
After making these changes, restart your development server to apply the new configuration.
Link to section: Understanding the Three Async ContextsUnderstanding the Three Async Contexts
Svelte 5.36 introduces await
support in three specific contexts, each serving different use cases.
Link to section: Top-Level Script AwaitTop-Level Script Await
You can now use await
directly in your component's script section:
<!-- UserProfile.svelte -->
<script>
const user = await fetch('/api/user/123').then(r => r.json());
const preferences = await fetch(`/api/user/${user.id}/preferences`).then(r => r.json());
</script>
<div class="profile">
<h1>Welcome, {user.name}</h1>
<p>Theme: {preferences.theme}</p>
</div>
This approach is ideal for data that doesn't change after the initial load and creates a loading boundary around the entire component.
Link to section: Derived Expression AwaitDerived Expression Await
For reactive async computations that depend on changing state, use await
within $derived
:
<!-- WeatherWidget.svelte -->
<script>
let city = $state('New York');
const weather = $derived(async () => {
if (!city) return null;
const response = await fetch(`/api/weather?city=${encodeURIComponent(city)}`);
return response.json();
});
</script>
<input bind:value={city} placeholder="Enter city name" />
{#if weather}
<div class="weather-info">
<p>Temperature: {weather.temperature}°F</p>
<p>Conditions: {weather.conditions}</p>
</div>
{/if}
The derived expression will re-evaluate whenever city
changes, automatically managing the async state updates.
Link to section: Markup AwaitMarkup Await
The most flexible option allows await
directly in your template:
<!-- ProductCard.svelte -->
<script>
let { productId } = $props();
async function fetchProduct(id) {
const response = await fetch(`/api/products/${id}`);
return response.json();
}
</script>
<div class="product-card">
{#await fetchProduct(productId)}
<div class="loading">Loading product...</div>
{:then product}
<h3>{product.name}</h3>
<p>${product.price}</p>
<p>{product.description}</p>
{:catch error}
<p class="error">Failed to load product: {error.message}</p>
{/await}
</div>
Link to section: Building a Practical Example: Dynamic DashboardBuilding a Practical Example: Dynamic Dashboard
Let's create a comprehensive example that demonstrates all three async contexts in a dashboard component.

Create a new file src/lib/Dashboard.svelte
:
<!-- Dashboard.svelte -->
<script>
// Top-level await for initial user data
const user = await fetch('/api/current-user').then(r => r.json());
const userPermissions = await fetch(`/api/users/${user.id}/permissions`).then(r => r.json());
// Reactive state for dashboard filters
let selectedDateRange = $state('7d');
let selectedMetric = $state('revenue');
// Derived async computation for analytics data
const analyticsData = $derived(async () => {
const params = new URLSearchParams({
range: selectedDateRange,
metric: selectedMetric,
userId: user.id
});
const response = await fetch(`/api/analytics?${params}`);
if (!response.ok) throw new Error('Analytics fetch failed');
return response.json();
});
// Function for on-demand data fetching
async function fetchRecentActivity(limit = 10) {
const response = await fetch(`/api/activity?limit=${limit}&userId=${user.id}`);
return response.json();
}
function updateDateRange(event) {
selectedDateRange = event.target.value;
}
function updateMetric(event) {
selectedMetric = event.target.value;
}
</script>
<div class="dashboard">
<header class="dashboard-header">
<h1>Welcome back, {user.name}</h1>
<p>Last login: {new Date(user.lastLogin).toLocaleDateString()}</p>
</header>
<div class="controls">
<select value={selectedDateRange} on:change={updateDateRange}>
<option value="1d">Last 24 Hours</option>
<option value="7d">Last 7 Days</option>
<option value="30d">Last 30 Days</option>
</select>
<select value={selectedMetric} on:change={updateMetric}>
<option value="revenue">Revenue</option>
<option value="users">Active Users</option>
<option value="orders">Orders</option>
</select>
</div>
<div class="dashboard-grid">
<!-- Analytics panel using derived await -->
<div class="panel analytics">
<h2>Analytics</h2>
{#await analyticsData}
<div class="loading-skeleton">
<div class="skeleton-bar"></div>
<div class="skeleton-bar"></div>
<div class="skeleton-bar"></div>
</div>
{:then data}
<div class="metric-value">
{#if selectedMetric === 'revenue'}
${data.value.toLocaleString()}
{:else}
{data.value.toLocaleString()}
{/if}
</div>
<div class="trend ${data.trend > 0 ? 'positive' : 'negative'}">
{data.trend > 0 ? '↗' : '↘'} {Math.abs(data.trend)}%
</div>
{:catch error}
<div class="error">
Failed to load analytics: {error.message}
</div>
{/await}
</div>
<!-- Activity panel using markup await -->
{#if userPermissions.includes('view_activity')}
<div class="panel activity">
<h2>Recent Activity</h2>
{#await fetchRecentActivity()}
<div class="loading">Loading recent activity...</div>
{:then activities}
<ul class="activity-list">
{#each activities as activity}
<li class="activity-item">
<span class="activity-time">
{new Date(activity.timestamp).toLocaleTimeString()}
</span>
<span class="activity-description">{activity.description}</span>
</li>
{/each}
</ul>
{:catch error}
<div class="error">Could not load activity feed</div>
{/await}
</div>
{/if}
</div>
</div>
<style>
.dashboard {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.dashboard-header {
margin-bottom: 2rem;
}
.dashboard-header h1 {
margin: 0 0 0.5rem 0;
color: #333;
}
.controls {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.controls select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.panel {
background: white;
border: 1px solid #e1e1e1;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.panel h2 {
margin: 0 0 1rem 0;
color: #555;
}
.loading-skeleton {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.skeleton-bar {
height: 1rem;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.metric-value {
font-size: 2rem;
font-weight: bold;
color: #2563eb;
}
.trend {
font-size: 0.875rem;
margin-top: 0.5rem;
}
.trend.positive { color: #059669; }
.trend.negative { color: #dc2626; }
.activity-list {
list-style: none;
padding: 0;
margin: 0;
}
.activity-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
}
.activity-time {
font-size: 0.75rem;
color: #666;
white-space: nowrap;
}
.activity-description {
flex: 1;
margin-left: 1rem;
}
.loading, .error {
padding: 2rem;
text-align: center;
color: #666;
}
.error {
color: #dc2626;
background: #fef2f2;
border-radius: 4px;
}
</style>
Link to section: Handling Synchronized UpdatesHandling Synchronized Updates
One crucial aspect of Svelte 5's async components is synchronized updates. When an await
expression depends on reactive state, Svelte ensures the UI doesn't show inconsistent intermediate states.
Create src/lib/SynchronizedExample.svelte
to demonstrate this behavior:
<!-- SynchronizedExample.svelte -->
<script>
let searchTerm = $state('');
let category = $state('all');
async function searchProducts(term, cat) {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 1000));
const response = await fetch(`/api/search?q=${term}&category=${cat}`);
return response.json();
}
</script>
<div class="search-interface">
<input
bind:value={searchTerm}
placeholder="Search products..."
/>
<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 class="results">
<p>Searching: "{searchTerm}" in {category}</p>
<p>Results: {await searchProducts(searchTerm, category)}</p>
</div>
</div>
In this example, if you rapidly change both searchTerm
and category
, Svelte ensures that the results display reflects both final values rather than showing intermediate mismatched states like "electronics results for books search term."
Link to section: Error Handling and Loading StatesError Handling and Loading States
Proper error handling is essential for production async components. Here's a robust pattern for managing different states:
<!-- RobustAsyncComponent.svelte -->
<script>
let retryCount = $state(0);
let { dataId } = $props();
async function fetchWithRetry(id, maxRetries = 3) {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(`/api/data/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
function retry() {
retryCount += 1;
}
</script>
<!-- Force re-evaluation by including retryCount in the await expression -->
{#await fetchWithRetry(dataId) then data}
<div class="content">
<h3>{data.title}</h3>
<p>{data.description}</p>
<p class="meta">Last updated: {new Date(data.updatedAt).toLocaleDateString()}</p>
</div>
{:catch error}
<div class="error-state">
<h3>Something went wrong</h3>
<p>Error: {error.message}</p>
<button onclick={retry}>Retry</button>
{#if retryCount > 0}
<p class="retry-info">Retry attempts: {retryCount}</p>
{/if}
</div>
{/await}
<style>
.error-state {
padding: 1rem;
background: #fee;
border: 1px solid #fcc;
border-radius: 4px;
text-align: center;
}
.retry-info {
font-size: 0.875rem;
color: #666;
}
</style>
Link to section: Performance Considerations and Best PracticesPerformance Considerations and Best Practices
Async components introduce new performance considerations. Here are key practices to follow:
Link to section: Avoiding Waterfall RequestsAvoiding Waterfall Requests
Instead of sequential awaits, parallelize independent requests:
<!-- Inefficient: Sequential requests -->
<script>
const user = await fetch('/api/user').then(r => r.json());
const posts = await fetch(`/api/users/${user.id}/posts`).then(r => r.json());
const comments = await fetch(`/api/users/${user.id}/comments`).then(r => r.json());
</script>
<!-- Efficient: Parallel requests where possible -->
<script>
const user = await fetch('/api/user').then(r => r.json());
const [posts, comments] = await Promise.all([
fetch(`/api/users/${user.id}/posts`).then(r => r.json()),
fetch(`/api/users/${user.id}/comments`).then(r => r.json())
]);
</script>
Link to section: Debouncing Reactive Async OperationsDebouncing Reactive Async Operations
For derived expressions that trigger on user input, implement debouncing:
<script>
let searchQuery = $state('');
let debouncedQuery = $state('');
let timeoutId;
$effect(() => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
debouncedQuery = searchQuery;
}, 300);
});
const searchResults = $derived(async () => {
if (!debouncedQuery.trim()) return [];
const response = await fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`);
return response.json();
});
</script>
<input bind:value={searchQuery} placeholder="Search..." />
{#await searchResults then results}
<!-- Display results -->
{/await}
Link to section: Integration with SvelteKit FeaturesIntegration with SvelteKit Features
Async components work seamlessly with SvelteKit's ecosystem. You can combine them with load functions for optimal data fetching strategies. Recent SvelteKit updates have enhanced this integration further.
<!-- +page.svelte -->
<script>
import { page } from '$app/stores';
// Initial data from load function
let { data } = $props();
// Additional async data based on URL parameters
const additionalData = $derived(async () => {
const searchParams = $page.url.searchParams;
const filter = searchParams.get('filter');
if (!filter) return null;
const response = await fetch(`/api/filtered-data?filter=${filter}`);
return response.json();
});
</script>
<h1>{data.title}</h1>
{#if additionalData}
{#await additionalData then extra}
<section class="filtered-content">
<!-- Display filtered content -->
</section>
{/await}
{/if}
Link to section: Testing Async ComponentsTesting Async Components
Testing async components requires special consideration for timing and mocking. Here's a Vitest example:
// Dashboard.test.js
import { render, screen } from '@testing-library/svelte';
import { vi, expect, test } from 'vitest';
import Dashboard from '../lib/Dashboard.svelte';
// Mock fetch
global.fetch = vi.fn();
test('displays loading state then data', async () => {
fetch
.mockResolvedValueOnce({
ok: true,
json: async () => ({ id: 1, name: 'John Doe', lastLogin: '2025-09-26' })
})
.mockResolvedValueOnce({
ok: true,
json: async () => (['view_activity'])
});
render(Dashboard);
// Component should show loading initially
expect(screen.getByText(/Loading/)).toBeInTheDocument();
// Wait for async operations to complete
await screen.findByText('Welcome back, John Doe');
expect(screen.getByText('Welcome back, John Doe')).toBeInTheDocument();
});
test('handles fetch errors gracefully', async () => {
fetch.mockRejectedValueOnce(new Error('Network error'));
render(Dashboard);
await screen.findByText(/Failed to load/);
expect(screen.getByText(/Failed to load/)).toBeInTheDocument();
});
Link to section: Migration from Traditional PatternsMigration from Traditional Patterns
If you're migrating from traditional async patterns, here's how common approaches translate:
Link to section: From onMount to Top-Level AwaitFrom onMount to Top-Level Await
<!-- Old pattern -->
<script>
import { onMount } from 'svelte';
let data = null;
let loading = true;
let error = null;
onMount(async () => {
try {
const response = await fetch('/api/data');
data = await response.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
});
</script>
<!-- New pattern -->
<script>
const data = await fetch('/api/data').then(r => r.json());
</script>
Link to section: From Stores to Derived ExpressionsFrom Stores to Derived Expressions
<!-- Old pattern -->
<script>
import { writable, derived } from 'svelte/store';
const query = writable('');
const results = derived(query, async ($query) => {
if (!$query) return [];
const response = await fetch(`/api/search?q=${$query}`);
return response.json();
});
</script>
<!-- New pattern -->
<script>
let query = $state('');
const results = $derived(async () => {
if (!query) return [];
const response = await fetch(`/api/search?q=${query}`);
return response.json();
});
</script>
Link to section: Production Deployment ConsiderationsProduction Deployment Considerations
When deploying async components to production, consider these factors:
Link to section: Server-Side RenderingServer-Side Rendering
Async components work with SSR, but ensure your server environment can handle the async operations:
// svelte.config.js
export default {
kit: {
adapter: adapter({
// Increase timeout for async operations
timeout: 30000
})
}
};
Link to section: Error BoundariesError Boundaries
Implement error boundaries at strategic points in your component tree:
<!-- ErrorBoundary.svelte -->
<script>
let { children } = $props();
let error = null;
function handleError(event) {
error = event.detail;
}
</script>
<svelte:window on:unhandledrejection={handleError} />
{#if error}
<div class="error-boundary">
<h2>Something went wrong</h2>
<button onclick={() => window.location.reload()}>Refresh Page</button>
</div>
{:else}
{@render children()}
{/if}
Async components in Svelte 5 represent a significant evolution in how we handle asynchronous operations in frontend applications. By reducing boilerplate, providing synchronized updates, and maintaining Svelte's characteristic simplicity, this feature enables more maintainable and performant applications. As you adopt these patterns, start with simple use cases and gradually incorporate more complex scenarios as you become comfortable with the new paradigms.
The experimental flag will be removed in Svelte 6, making async components a core part of the framework. Start experimenting with them now to prepare for this transition and to take advantage of the improved developer experience they provide.