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

Popular posts from this blog

Learning to Automate My Side Projects with SWE-agent + GitLab

Ship-Ready Web Essentials: Search, Sitemap, Metadata & Icons (SvelteKit)

Kubernetes Secrets Management — SOPS + age (GitOps‑friendly)