The Stale Data Problem

You submit a form. The server updates the database. The page… shows stale data.

This is the classic cache invalidation problem. Your load function fetched data before the mutation. Now that data is outdated. How does the page know to refresh?

SvelteKit gives you explicit control with two tools: invalidate() to trigger refreshes, and depends() to declare what a load function needs.

The Default Behavior

After a form action completes, SvelteKit automatically reruns all load functions for the current page. This is usually what you want.

// routes/todos/+page.server.ts
export async function load() {
  return {
    todos: await db.getTodos()
  };
}

export const actions = {
  add: async ({ request }) => {
    const formData = await request.formData();
    await db.addTodo(formData.get('text'));
    // Page load functions rerun automatically
  }
};
<!-- routes/todos/+page.svelte -->
<script>
  import { enhance } from '$app/forms';
  let { data } = $props();
</script>

<ul>
  {#each data.todos as todo}
    <li>{todo.text}</li>
  {/each}
</ul>

<form method="POST" action="?/add" use:enhance>
  <input name="text" required>
  <button>Add</button>
</form>

Add a todo, and the list updates. No extra code needed.

When Automatic Isn’t Enough

The automatic refresh only affects the current page’s load functions. What about:

  • Data loaded by parent layouts?
  • Data on other pages that should update?
  • External mutations (WebSocket messages, other browser tabs)?

For these cases, you need explicit invalidation.

invalidate() and depends()

The depends() function declares that a load function depends on a custom identifier. The invalidate() function triggers a refresh of all load functions that depend on that identifier.

// routes/+layout.server.ts
export async function load({ depends }) {
  depends('app:user');  // Declare dependency
  
  return {
    user: await db.getCurrentUser()
  };
}
// routes/settings/+page.server.ts
export const actions = {
  updateProfile: async ({ request }) => {
    await db.updateProfile(/* ... */);
    // The layout's load function will rerun
  }
};
<!-- routes/settings/+page.svelte -->
<script>
  import { enhance } from '$app/forms';
  import { invalidate } from '$app/navigation';
  
  let { data } = $props();
</script>

<form 
  method="POST" 
  action="?/updateProfile" 
  use:enhance={() => {
    return async ({ update }) => {
      await update();
      // Trigger refresh of anything depending on 'app:user'
      invalidate('app:user');
    };
  }}
>
  <input name="name" value={data.user.name}>
  <button>Save</button>
</form>

When invalidate('app:user') is called, any load function that called depends('app:user') will rerun.

Naming Conventions

Use a consistent naming scheme for dependency identifiers:

// Good: namespaced, descriptive
depends('app:user');
depends('app:todos');
depends('app:projects');
depends('app:notifications');

// Avoid: generic, collision-prone
depends('data');
depends('user');
depends('list');

The app: prefix is a convention, not a requirement. Use whatever makes sense for your project, but be consistent.

invalidateAll()

When you need to refresh everything, use invalidateAll():

<script>
  import { invalidateAll } from '$app/navigation';
  
  async function handleLogout() {
    await fetch('/api/logout', { method: 'POST' });
    invalidateAll();  // Refresh all data
  }
</script>

This reruns every load function on the current page and its layouts. Use sparingly—it’s a sledgehammer when you often need a scalpel.

URL-Based Invalidation

Load functions automatically depend on the URLs they fetch. You can invalidate by URL pattern:

// routes/dashboard/+page.ts
export async function load({ fetch }) {
  // This creates an automatic dependency on this URL
  const stats = await fetch('/api/stats').then(r => r.json());
  return { stats };
}
<script>
  import { invalidate } from '$app/navigation';
  
  async function refreshStats() {
    // Invalidate anything that fetched from /api/stats
    invalidate('/api/stats');
  }
</script>

You can also use functions for pattern matching:

<script>
  import { invalidate } from '$app/navigation';
  
  function refreshAllApi() {
    // Invalidate anything that fetched from /api/*
    invalidate((url) => url.pathname.startsWith('/api/'));
  }
</script>

Real-World Patterns

Dashboard with stats

The dashboard shows statistics. Multiple pages can change those statistics.

// routes/+layout.server.ts
export async function load({ depends }) {
  depends('app:stats');
  
  return {
    stats: await db.getDashboardStats()
  };
}
// routes/tasks/+page.server.ts
export const actions = {
  complete: async ({ request }) => {
    await db.completeTask(/* ... */);
    // Stats will refresh via the layout's depends('app:stats')
  }
};
<!-- routes/tasks/+page.svelte -->
<script>
  import { enhance } from '$app/forms';
  import { invalidate } from '$app/navigation';
</script>

<form 
  method="POST" 
  action="?/complete" 
  use:enhance={() => {
    return async ({ update }) => {
      await update();
      invalidate('app:stats');
    };
  }}
>
  <!-- ... -->
</form>

Now completing a task on the tasks page refreshes the stats in the layout.

Notifications badge

A notification count in the header should update when notifications are read.

// routes/+layout.server.ts
export async function load({ depends, locals }) {
  depends('app:notifications');
  
  return {
    user: locals.user,
    unreadCount: await db.getUnreadNotificationCount(locals.user.id)
  };
}
<!-- routes/notifications/+page.svelte -->
<script>
  import { invalidate } from '$app/navigation';
  
  let { data } = $props();
  
  async function markAllRead() {
    await fetch('/api/notifications/mark-read', { method: 'POST' });
    invalidate('app:notifications');
  }
</script>

<button onclick={markAllRead}>Mark all read</button>

Real-time updates

For data that changes outside user actions (WebSocket, polling), invalidate when you receive updates:

<script>
  import { invalidate } from '$app/navigation';
  import { onMount } from 'svelte';
  
  onMount(() => {
    const ws = new WebSocket('/ws');
    
    ws.onmessage = (event) => {
      const { type } = JSON.parse(event.data);
      
      if (type === 'todo-updated') {
        invalidate('app:todos');
      }
    };
    
    return () => ws.close();
  });
</script>

When NOT to Invalidate

Not every change needs invalidation. Local state changes don’t need it:

<script>
  let { data } = $props();
  
  // Local UI state - no invalidation needed
  let selectedId = $state(null);
  let isExpanded = $state(false);
  let searchQuery = $state('');
  
  // Derived from loaded data - updates automatically when data changes
  let filtered = $derived(
    data.items.filter(i => i.name.includes(searchQuery))
  );
</script>

Only invalidate when:

  • Server data has changed
  • Multiple load functions need the updated data
  • The change happened outside the normal form action flow

Optimistic UI vs. Invalidation

You have two strategies for showing updates:

Invalidation (refetch): Wait for the server, then update with real data.

  • Pros: Always accurate, simple mental model
  • Cons: Slower perceived performance, loading states

Optimistic UI: Update immediately, reconcile with server later.

  • Pros: Instant feedback, snappy feel
  • Cons: More complex, needs rollback handling

For most CRUD operations, invalidation is simpler and sufficient. Users can wait 200ms for accurate data. Save optimistic UI for high-frequency interactions where perceived speed matters.

<!-- Simple invalidation approach -->
<form 
  method="POST" 
  action="?/complete"
  use:enhance={() => {
    return async ({ update }) => {
      await update();  // Wait for server, then update UI
    };
  }}
>

If you decide you need optimistic UI later, you can add it. Starting with invalidation keeps things simple.

The Mental Model

Think of invalidation as a pub/sub system:

  1. Load functions subscribe with depends('topic')
  2. Actions or events publish with invalidate('topic')
  3. Subscribers rerun and get fresh data

Keep your topics focused. app:user for user data. app:todos for todos. Don’t create one giant topic that refreshes everything—that defeats the purpose.

Explicit invalidation gives you control. Use it to keep your UI in sync without over-fetching or building complex cache management.


Next up: Components need to talk to each other. Props down, callbacks up—but Svelte 5 gives you more tools. We’ll explore $props, $bindable, and snippets for flexible component communication.