Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 053 / 100

Performance in SvelteKit: a hands-on guide for engineers

Keep apps fast.

Expona Admin7 min read

SvelteKit gives us great defaults, but defaults aren't always enough when users expect snappy interactions and fast page loads. We've all watched metrics climb as features pile up, then scrambled to figure out why a project that felt fast in development runs like molasses in production. Performance in SvelteKit: a hands-on guide for engineers means taking a systematic approach—measuring what matters, understanding where slowness hides, and applying concrete fixes that compound.

Preloading: getting ahead of user intent

One of the quickest wins in SvelteKit performance is preloading data before a user even clicks. The framework gives us data-sveltekit-preload-data, a directive that kicks off data loading on hover or focus, cutting perceived latency significantly. This works because most users pause briefly before clicking a link—we use that window.

Let's say we're building a product listing page with category filters. Users hover over category links to see what's available. Without preloading, they click, wait for the load function to run, then see the new products. With preloading, the data request fires on hover, and by the time they click, we already have the category data in flight or cached.

typescript
<a href="/products/electronics" data-sveltekit-preload-data="hover">
  Electronics
</a>

<a href="/products/books" data-sveltekit-preload-data="viewport">
  Books
</a>

The directive accepts three values: "hover" preloads on mouse enter or focus, "viewport" preloads when the link enters the viewport (useful for below-the-fold navigation), and "eager" preloads immediately. There's a fourth option—data-sveltekit-preload-code—that preloads just the route's code bundle without fetching data.

Be selective here. Preloading every link on a page burns bandwidth and can create false urgency on server resources. We typically reserve full preload for high-confidence flows—checkout buttons, primary navigation, search results. For exploratory browsing, viewport preload balances engagement with resource efficiency.

Code splitting: let the framework work

SvelteKit automatically code-splits at the route level. Each route becomes its own JavaScript bundle, and only the code needed for the current page loads in the browser. This is powerful, but we need to understand what it means in practice and where we can optimize further.

Consider a SaaS dashboard with a heavy reporting page. That page might import a large charting library, date utilities, and complex data transformation functions. Without code splitting, every user—even those only checking their account settings—would download that charting library. With splitting, the library chunk sits dormant until someone navigates to the reporting route.

typescript
// src/routes/dashboard/reports/+page.svelte
<script>
  import Chart from 'chart.js'; // Only loaded when this route is active
  import { generateReport } from './reportUtils';
  
  export let data;
</script>

<div class="chart-container">
  <canvas bind:this={chartCanvas}></canvas>
</div>

Beyond routes, we can use dynamic imports to split expensive components or libraries within a route. If a modal containing a form only appears on user interaction, lazy-load it.

typescript
import { onMount } from 'svelte';

let AdvancedFilters;

async function openFilters() {
  AdvancedFilters = (await import('./AdvancedFilters.svelte')).default;
  showModal = true;
}

onMount(() => {
  // Component only loads when the user triggers the action
});

Run npm run build and inspect the .svelte-kit/output/client directory. You'll see route bundles and shared chunks. Shared chunks contain code used across multiple routes—the framework extracts this automatically. Large shared chunks are a sign we're importing heavy dependencies at high levels in the tree.

Avoid waterfalls: load data in parallel

A waterfall happens when one data request blocks another. Request A completes, then request B starts. Request B completes, then request C starts. In a chain of three or four requests, latency multiplies. SvelteKit's load function makes parallel loading natural, but it's easy to slip into waterfalls without noticing.

typescript
// ❌ Waterfall: each request waits for the previous
export async function load({ fetch }) {
  const user = await fetch('/api/user').then(r => r.json());
  const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json());
  const comments = await fetch(`/api/comments?userId=${user.id}`).then(r => r.json());
  
  return { user, posts, comments };
}

The issue: we need the user ID before fetching posts and comments, but posts and comments don't depend on each other. The better approach:

typescript
// ✅ Parallel: fetch what you can at the same time
export async function load({ fetch }) {
  const userPromise = fetch('/api/user').then(r => r.json());
  
  const user = await userPromise;
  
  // Now fetch posts and comments in parallel
  const [posts, comments] = await Promise.all([
    fetch(`/api/posts?userId=${user.id}`).then(r => r.json()),
    fetch(`/api/comments?userId=${user.id}`).then(r => r.json())
  ]);
  
  return { user, posts, comments };
}

This cuts load time from the sum of all requests to the sum of the longest dependency chain. In production, we often pair this with database query optimization—fetch user data with a join that pulls related posts or comments in a single query, eliminating the API calls entirely.

Watch for hidden waterfalls too. If your layout load function fetches user settings, and every page load runs that function, every page waits for settings. Consider caching user data in the browser or session store, fetching fresh data only when needed.

Measure: Lighthouse and real user metrics

Performance work without measurement is guesswork. We need two perspectives: synthetic (Lighthouse in CI/CD) and real user monitoring (RUM).

Lighthouse runs in a controlled environment. It's reproducible and catches regressions early. We run it locally during development and in CI to block merges that degrade performance.

typescript
// In your CI pipeline (GitHub Actions example)
- name: Run Lighthouse
  run: |
    npm install -g @lhci/cli@*
    lhci autorun

Set up a lighthouserc.json to define budgets:

typescript
{
  "ci": {
    "collect": {
      "url": ["http://localhost:5173"],
      "numberOfRuns": 3
    },
    "assert": {
      "preset": "lighthouse:recommended",
      "assertions": {
        "first-contentful-paint": ["error", { "maxNumericValue": 1500 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
      }
    }
  }
}

Real user metrics tell a different story. Lighthouse runs on a fast connection in a quiet environment. Real users on 4G with a hundred browser tabs open have different experiences. Tools like Sentry, DataDog, or the Web Vitals API capture what users actually experience.

typescript
// src/app.html or a layout component
<script>
  import { onNavigate } from '$app/navigation';
  import { getCLS, getFID, getFCP, getLCP, getTTFB } from 'web-vitals';
  
  function sendMetric(metric) {
    // Send to your analytics endpoint
    console.log(metric.name, metric.value);
  }
  
  getCLS(sendMetric);
  getFID(sendMetric);
  getFCP(sendMetric);
  getLCP(sendMetric);
  getTTFB(sendMetric);
</script>

Focus on Core Web Vitals: Largest Contentful Paint (LCP, target <2.5s), First Input Delay (FID, target <100ms), and Cumulative Layout Shift (CLS, target <0.1). These correlate with user satisfaction and search ranking.

Common mistakes

  • Preloading too aggressively. Every link preloaded means unnecessary requests. Reserve preload for high-confidence user paths.
  • Ignoring bundle size. Code splitting helps, but large dependencies still matter. Audit your node_modules with npm ls and tools like source-map-explorer.
  • Waterfalls in load functions. It's tempting to fetch sequentially—resist it. Use Promise.all and database joins to parallelize.
  • Trusting Lighthouse alone. A fast Lighthouse score on a 4G desktop doesn't mean mobile users on 3G see the same. Always pair synthetic and real metrics.
  • Forgetting layout shifts. Skeleton loaders, lazy images without dimensions, and ads are common culprits. Set explicit widths and heights to prevent layout thrashing.

Key takeaways

  • Use data-sveltekit-preload-data on high-confidence navigation to cut perceived latency, but be selective to avoid wasting bandwidth.
  • Lean on SvelteKit's automatic code splitting at the route level, and use dynamic imports within routes for heavy features that aren't always needed.
  • Parallelize data loading in load functions with Promise.all; map sequential API calls to database joins whenever possible.
  • Measure with both Lighthouse (synthetic, reproducible) and real user metrics (actual experience); focus on Core Web Vitals and set performance budgets in CI.
  • Performance is a process, not a one-time fix—iterate, measure, refactor, and repeat as user expectations and traffic patterns evolve.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles