4 min read
On this page

Invalidation & Streaming

Overview

SvelteKit caches load function results during client-side navigation. When a user navigates from /blog to /about and back to /blog, SvelteKit does not re-run the blog load function by default if the data has not been invalidated. This is efficient, but sometimes you need fresh data. Invalidation gives you control over when data is refetched. Streaming lets you show parts of the page while slow data is still loading.

invalidate() & invalidateAll()

The invalidate function tells SvelteKit to re-run specific load functions. The invalidateAll function re-runs all load functions on the current page.

<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
  import { invalidate, invalidateAll } from '$app/navigation';

  let { data } = $props();

  async function refreshStats() {
    // Re-runs any load function that depends on '/api/stats'
    await invalidate('/api/stats');
  }

  async function refreshEverything() {
    // Re-runs all load functions on this page
    await invalidateAll();
  }
</script>

<h1>Dashboard</h1>
<p>Revenue: {data.stats.revenue}</p>
<button onclick={refreshStats}>Refresh Stats</button>
<button onclick={refreshEverything}>Refresh All</button>

When you call invalidate('/api/stats'), SvelteKit looks at all load functions on the current page and re-runs those that used fetch('/api/stats'). The page re-renders with the new data.

How SvelteKit Tracks Dependencies

SvelteKit automatically tracks which URLs a load function fetches. When you call invalidate with a URL, it matches against those tracked URLs.

// src/routes/dashboard/+page.ts
export const load: PageLoad = async ({ fetch }) => {
  // SvelteKit tracks that this load function depends on '/api/stats'
  const statsResponse = await fetch('/api/stats');
  const stats = await statsResponse.json();

  // And depends on '/api/notifications'
  const notifsResponse = await fetch('/api/notifications');
  const notifications = await notifsResponse.json();

  return { stats, notifications };
};
<script lang="ts">
  import { invalidate } from '$app/navigation';

  // Only re-runs the load function if it fetched '/api/stats'
  // Since our load fetches '/api/stats', it will re-run
  // But if another load function only fetches '/api/users', it won't
  await invalidate('/api/stats');
</script>

depends() for Custom Dependencies

Sometimes a load function does not use fetch but still needs to be invalidatable. The depends function registers custom dependency keys.

// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ depends }) => {
  // Register a custom dependency
  depends('app:dashboard-stats');

  const stats = await db.stats.aggregate({
    where: { period: 'current_month' }
  });

  return { stats };
};
<script lang="ts">
  import { invalidate } from '$app/navigation';

  async function refresh() {
    // This re-runs the load function that called depends('app:dashboard-stats')
    await invalidate('app:dashboard-stats');
  }
</script>

Custom dependencies must use a URI-like format with a colon. The app: prefix is a convention for application-specific dependencies. You could also use data:stats, custom:metrics, or any other prefix.

Combining depends() with Real-Time Updates

<!-- src/routes/chat/+page.svelte -->
<script lang="ts">
  import { invalidate } from '$app/navigation';
  import { onMount } from 'svelte';

  let { data } = $props();

  onMount(() => {
    const eventSource = new EventSource('/api/chat/events');

    eventSource.addEventListener('new-message', () => {
      invalidate('app:messages');
    });

    return () => eventSource.close();
  });
</script>

{#each data.messages as message}
  <div class="message">
    <strong>{message.author}</strong>: {message.text}
  </div>
{/each}
// src/routes/chat/+page.server.ts
export const load: PageServerLoad = async ({ depends }) => {
  depends('app:messages');

  const messages = await db.message.findMany({
    orderBy: { createdAt: 'asc' },
    take: 50
  });

  return { messages };
};

When a server-sent event arrives, the client invalidates the messages dependency, which triggers a refetch. The page updates with new messages.

Streaming with Promises

Load functions can return promises that resolve later. SvelteKit streams the page, rendering immediately with the data that is ready and filling in the rest as promises resolve.

// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  // This data is needed immediately
  const user = await db.user.findUnique({ where: { id: 1 } });

  // These can load in the background
  const slowAnalytics = db.analytics.computeSummary();  // no await
  const slowRecommendations = fetchRecommendations();    // no await

  return {
    user,                                // resolved, available immediately
    analytics: slowAnalytics,            // promise, streams when ready
    recommendations: slowRecommendations // promise, streams when ready
  };
};
<!-- src/routes/dashboard/+page.svelte -->
<script lang="ts">
  let { data } = $props();
</script>

<h1>Welcome back, {data.user.name}</h1>

{#await data.analytics}
  <div class="skeleton">Loading analytics...</div>
{:then analytics}
  <div class="analytics">
    <h2>Analytics</h2>
    <p>Views: {analytics.views}</p>
    <p>Revenue: {analytics.revenue}</p>
  </div>
{:catch error}
  <div class="error">Failed to load analytics: {error.message}</div>
{/await}

{#await data.recommendations}
  <div class="skeleton">Loading recommendations...</div>
{:then recommendations}
  <h2>Recommended for You</h2>
  {#each recommendations as item}
    <div class="card">{item.title}</div>
  {/each}
{:catch}
  <p>Could not load recommendations.</p>
{/await}

The page renders immediately with the user's name. The analytics and recommendations sections show loading states, then fill in as their data arrives. The user sees content progressively instead of waiting for the slowest query to finish.

The Waterfall Problem

Waterfalls happen when data fetches run sequentially instead of in parallel. Each fetch waits for the previous one to complete, adding latency.

// BAD: waterfall — each await blocks the next
export const load: PageServerLoad = async ({ params }) => {
  const post = await db.post.findUnique({ where: { slug: params.slug } });
  const author = await db.user.findUnique({ where: { id: post.authorId } });
  const comments = await db.comment.findMany({ where: { postId: post.id } });
  const related = await db.post.findMany({ where: { categoryId: post.categoryId } });

  return { post, author, comments, related };
};
Timeline (waterfall):
  post ─────────|
                 author ──────|
                               comments ──────|
                                               related ──────|
  Total: ~800ms (200ms + 200ms + 200ms + 200ms)

Fixing Waterfalls with Promise.all

When fetches do not depend on each other, run them in parallel.

// GOOD: parallel fetches — independent queries run simultaneously
export const load: PageServerLoad = async ({ params }) => {
  const post = await db.post.findUnique({ where: { slug: params.slug } });

  // These three don't depend on each other, only on post
  const [author, comments, related] = await Promise.all([
    db.user.findUnique({ where: { id: post.authorId } }),
    db.comment.findMany({ where: { postId: post.id } }),
    db.post.findMany({ where: { categoryId: post.categoryId, NOT: { id: post.id } } })
  ]);

  return { post, author, comments, related };
};
Timeline (parallel):
  post ─────────|
                 author ──────|
                 comments ────|
                 related ─────|
  Total: ~400ms (200ms + 200ms)

Combining Parallel Loading with Streaming

The most performant pattern fetches critical data synchronously and streams non-critical data.

// BEST: parallel + streaming
export const load: PageServerLoad = async ({ params }) => {
  const post = await db.post.findUnique({ where: { slug: params.slug } });

  // Critical: needed for the initial render
  const author = await db.user.findUnique({ where: { id: post.authorId } });

  // Non-critical: can stream in later
  const comments = db.comment.findMany({
    where: { postId: post.id },
    orderBy: { createdAt: 'desc' }
  });

  const related = db.post.findMany({
    where: { categoryId: post.categoryId, NOT: { id: post.id } },
    take: 5
  });

  return {
    post,
    author,
    comments,   // promise — streams when ready
    related     // promise — streams when ready
  };
};
Timeline (parallel + streaming):
  post ────|
           author ────| → page renders here
           comments ───────| → streams in
           related ────────| → streams in
  Total perceived: ~300ms (user sees content)
  Total complete: ~500ms (everything loaded)

Layout Load Waterfalls

A subtle waterfall occurs between layout and page load functions. Each layout load must complete before its children start, because children might call parent().

Waterfall between layouts:
  root layout load ────|
                        dashboard layout load ────|
                                                   page load ────|

If a child does not need parent data, SvelteKit can run them in parallel. But if any child calls parent(), it must wait. Avoid unnecessary parent() calls.

// AVOID: unnecessary parent() call creates a waterfall
export const load: PageServerLoad = async ({ parent, params }) => {
  const parentData = await parent(); // forces sequential execution
  // parentData is not even used below
  const items = await db.item.findMany({ where: { slug: params.slug } });
  return { items };
};
// BETTER: skip parent() when you don't need it
export const load: PageServerLoad = async ({ params }) => {
  const items = await db.item.findMany({ where: { slug: params.slug } });
  return { items };
};

Common Pitfalls

  • Overusing invalidateAll(): This re-runs every load function on the page. If you only need to refresh one piece of data, use invalidate() with a specific URL or custom dependency. Over-invalidation wastes bandwidth and causes unnecessary re-renders.
  • Forgetting depends() for non-fetch load functions: If your load function queries a database directly instead of using fetch, SvelteKit has no URL to track. Call depends() with a custom key so invalidate() can target it.
  • Not handling streaming errors: Streamed promises can reject. Always use {:catch} in {#await} blocks. An unhandled rejection in streamed data breaks that section of the page.
  • Streaming data that is needed for SEO: Search engines see the initial HTML before streamed data arrives. If content is critical for SEO, await it in the load function so it is in the initial response.
  • Creating waterfalls with await chains: Sequential await statements for independent data are the most common performance mistake. Use Promise.all() for independent fetches and streaming for non-critical data.

Key Takeaways

  • invalidate(url) re-runs load functions that depend on a specific URL. invalidateAll() re-runs all load functions on the current page.
  • depends('app:custom-key') registers custom dependencies for load functions that do not use fetch. Invalidate them by key.
  • Returning unresolved promises from load functions enables streaming. The page renders immediately with available data and fills in streamed sections as promises resolve.
  • Avoid waterfalls by running independent fetches in parallel with Promise.all(). Combine parallel loading with streaming for the best perceived performance.
  • Be deliberate about what to await (critical, SEO-important data) versus what to stream (secondary content, slow queries). The split determines how fast your page feels.