How It Works: Skeleton Screens & Lazy Loading in Svelte/SvelteKit (Detailed Guide
Updated: 2025-08-24
Summary
Skeleton screens replace empty states with shaped placeholders while data loads. Lazy loading defers heavy code/assets until needed. Together, they cut perceived wait time and real bandwidth.
When to use
- Lists, dashboards, and profile pages with remote data
- Routes with large JS bundles or heavy components (charts, editors)
- Navigations where SSR can’t pre-warm everything
Checklist
- Pre-size containers to avoid layout shift
- Use `{@await}` with a single, tasteful skeleton per content block
- Lazy-load only components that are truly heavy
- Respect reduced-motion; keep shimmer subtle or off
Project setup
npm create svelte@latest myapp
cd myapp && npm install
npm run dev
Route-level deferred data with {@await}
// src/routes/items/+page.js
export async function load({ fetch }) {
// don't await; return the Promise to drive {@await} in the page
const items = fetch('/api/items').then((r) => r.json());
return { items };
}
<!-- src/routes/items/+page.svelte -->
<script>
export let data;
import ListSkeleton from '$lib/ListSkeleton.svelte';
</script>
{@await data.items}
<ListSkeleton />
{:then items}
{#each items as item}
<article class="row">
<h3>{item.title}</h3>
<p>{item.summary}</p>
</article>
{/each}
{:catch err}
<p class="error">Failed to load: {err.message}</p>
{/await}
Component-level skeletons
<!-- src/lib/ListSkeleton.svelte -->
<div class="s-row">
<div class="s-avatar" />
<div class="s-lines">
<div class="s-line" /><div class="s-line short" />
</div>
</div>
<style>
.s-row { display:flex; gap:12px; align-items:center; }
.s-avatar { width:48px; height:48px; border-radius:8px; background:#e6e6e6; }
.s-lines { flex:1; }
.s-line { height:12px; margin:6px 0; background:#e6e6e6; }
.short { width:60%; }
@media (prefers-reduced-motion: no-preference) {
.s-line, .s-avatar { animation: pulse 1.2s infinite ease-in-out; }
@keyframes pulse { 0%{opacity:.7}50%{opacity:1}100%{opacity:.7} }
}
</style>
Lazy loading heavy components with dynamic import + intersection observer
<!-- src/routes/charts/+page.svelte -->
<script>
let Chart, visible = false;
async function loadChart() {
if (!Chart) {
const mod = await import('$lib/BigChart.svelte');
Chart = mod.default;
}
visible = true;
}
</script>
<div use:intersect={{ once: true, handler: loadChart }} style="min-height:320px">
{#if visible && Chart}
<Chart />
{:else}
<div class="skeleton chart">Loading chart…</div>
{/if}
</div>
Route-splitting by layout boundary
- Put heavy views under their own `+layout.svelte` so the rest of the app doesn’t pay for their JS.
- Co-locate data `+page.server.js` (for SSR) and `+page.js` (for client) only where you need them.
Invalidation & refresh without jank
// Re-fetch data after a mutation without blowing away the page
import { invalidate } from '$app/navigation';
await fetch('/api/items/123', { method: 'PUT', body: JSON.stringify(update) });
await invalidate('/api/items'); // triggers {@await data.items} to refetch
Accessibility & UX notes
- Skeletons must match the final layout size to avoid jumpiness.
- Don’t trap focus inside skeletons; they’re presentational.
- Keep color contrast reasonable; consider respecting user themes.
Common pitfalls
- Too many skeleton blocks → visual noise.
- Returning awaited data from `load` (no {@await}) → no skeleton on client navs.
- Lazy-loading everything → defeats caching; be strategic.
Performance tips
- Preload critical routes with `<link rel='modulepreload'>` where needed.
- Use `maxage` headers on API responses to enable HTTP caching.
- Measure with Web Vitals (LCP/INP) to confirm wins.
Taylor Swift
“Are we out of the woods yet?” — Out of the Woods
Comments
Post a Comment