Error tracking for SvelteKit: the complete guide

`handleError` hooks on both client and server, plus the SSR-vs-client error stream split that matters for real triage.

·
sveltekittutorial

You've just deployed a major update to your SvelteKit app. Your server-side logs look clean, the deploy went green, and you're ready to call it a day. But your conversion rate is quietly cratering. Somewhere between the server-rendered HTML and the client-side hydration, a silent TypeError is breaking your "Add to Cart" button. Nobody filed a bug. Nobody complained. The users just left.

This is the SvelteKit triage problem in a nutshell. An error in a SvelteKit app isn't just a stack trace; it's a piece of state caught in transit between a Cloudflare Worker and a Chromium browser. If you're only watching one side of that bridge, you're seeing half the bug at best. This guide goes past the "install the SDK and you're done" tutorials to address the architectural split brain that makes SvelteKit error tracking genuinely hard.

The SvelteKit Error Architecture: Server vs. Client

SvelteKit is a multi-platform framework by design. The same load function can execute in a Node process, a Bun runtime, or a Cloudflare Worker during server-side rendering, and then run again in the browser during client-side navigation. That dual nature is the root of most confusion. An error that throws cleanly during SSR might behave completely differently when the same code path runs client-side after the user clicks a link.

Concretely: when a user loads a page fresh, your load runs on the server. When that same user navigates within the app via a link, SvelteKit skips the server round-trip where it can and runs load in the browser. Same function, two radically different runtimes, two different failure modes. You cannot reason about SvelteKit errors without first knowing where the code was running when it blew up.

The handleError Hook: Your Central Command

SvelteKit gives you two hooks that act as catch-all funnels for unexpected exceptions: handleError in hooks.server.ts and handleError in hooks.client.ts. Any error that isn't an "expected" one (more on that distinction below) bubbles into these hooks. This is your single best integration point for an error tracker. Instead of sprinkling try/catch across every route, you centralize capture in two files.

One critical detail people miss: in SvelteKit 2.0, the handleError signature changed. It now receives an object with error, event, status, and message fields rather than the leaner 1.0 shape. The status and message fields let you decide whether something is worth reporting at all before you pay the cost of serializing it.

Expected vs. Unexpected Errors: the error() helper

SvelteKit draws a hard line between expected errors and unexpected ones, and your error tracker should respect that line or it will drown in noise. When you call the error() helper from @sveltejs/kit, you're telling the framework "this is a known, handled condition"—a 404, a 403, a validation failure. Those do not trigger handleError. Only thrown runtime exceptions do.

import { error } from '@sveltejs/kit';

// EXPECTED: a clean 404. Does NOT hit handleError.
export async function load({ params }) {
  const product = await getProduct(params.id);
  if (!product) {
    throw error(404, 'Product not found');
  }
  return { product };
}

// UNEXPECTED: a real runtime exception. This DOES hit handleError.
export async function load() {
  const data = await db.query('SELECT ...'); // throws on timeout
  return { data };
}

The takeaway: design your loaders so that anything reachable through normal user behavior throws error(), and reserve raw exceptions for genuine bugs. That single habit keeps your error stream signal-rich.

Implementing Server-Side Tracking in hooks.server.ts

The server hook is where you catch exceptions in whatever runtime SvelteKit is deployed to—Cloudflare Workers, Node, Bun, Deno. This is also where you have the richest context, because you hold the full event object: the URL, the request method, headers, and route ID.

Setting up the handleError server hook

A robust server-side handleError initializes a Sentry-compatible SDK and forwards the exception along with request context. The goal is to capture enough to reproduce the failure without leaking anything sensitive.

// src/hooks.server.ts
import type { HandleServerError } from '@sveltejs/kit';
import * as Sentry from '@sentry/sveltekit';

Sentry.init({
  dsn: 'https://your-key@glitchreplay.com/0',
  tracesSampleRate: 1.0,
});

export const handleError: HandleServerError = ({ error, event, status }) => {
  // Don't report expected 4xx; those are handled UX, not bugs.
  if (status < 500) return;

  const errorId = crypto.randomUUID();

  Sentry.captureException(error, {
    tags: { route: event.route.id ?? 'unknown' },
    extra: {
      errorId,
      method: event.request.method,
      url: event.url.pathname, // path only, never the query string
    },
  });

  // Return a safe shape for the client; never leak the raw stack.
  return { message: 'Internal Error', errorId };
};

Capturing request metadata without leaking PII

Notice what is not in that payload. We send event.url.pathname, not event.url.href—query strings are where session tokens, email addresses, and reset codes hide. We don't forward the raw Authorization header or cookies. If you need user context for triage, attach a hashed or pseudonymous user ID, not the email. Our guide on masking PII goes deeper, but the rule of thumb is: scrub at the edge, before the data ever leaves your runtime.

Handling failures in server-only load functions

Functions in +page.server.ts and +layout.server.ts only ever run on the server, which makes them easier to reason about—but they're also where your database and external API calls live, so they're your highest-volume source of real 500s. A timeout against Cloudflare D1, a rejected fetch to a payment provider, a null deref on an unexpected API shape: all of these surface here, all of them hit handleError, and all of them deserve a fingerprint that groups by the failing dependency rather than by the random request URL.

Implementing Client-Side Tracking in hooks.client.ts

The browser is a different beast. Here you're contending with hydration, with user-triggered interactions in onMount and Svelte actions, and with the general chaos of third-party scripts and browser extensions injecting themselves into your page.

Catching the dreaded hydration mismatch

Hydration is the process where SvelteKit takes the static HTML the server rendered and "wakes it up" by attaching client-side reactivity. A mismatch happens when the DOM the client expects doesn't match the DOM the server sent—often because something non-deterministic ran on both sides (a new Date(), a random value, a localStorage read that only exists in the browser). The symptom is brutal: the page looks fine, but interactivity is silently broken because Svelte bailed out of hydration. If you've fought React's version of this, our React hydration error 418 writeup covers the same class of bug from the other side of the fence.

// src/hooks.client.ts
import type { HandleClientError } from '@sveltejs/kit';
import * as Sentry from '@sentry/sveltekit';

Sentry.init({
  dsn: 'https://your-key@glitchreplay.com/0',
  replaysSessionSampleRate: 1.0,
});

export const handleError: HandleClientError = ({ error, event, status }) => {
  Sentry.captureException(error, {
    tags: { route: event.route.id ?? 'unknown', side: 'client' },
    extra: { status },
  });
  return { message: 'Something went wrong on this page.' };
};

Tracking errors in onMount and Svelte actions

Errors thrown inside onMount callbacks or inside a Svelte use:action don't always bubble to handleError cleanly, because they fire asynchronously after the component tree has settled. For these, you want a global unhandledrejection listener as a backstop—the Sentry-compatible SDK installs one for you, but if you're rolling your own capture, register it explicitly so a rejected promise in an action handler doesn't vanish.

Why client errors lack the "why"

Here's the frustrating part: a client-side error frequently has no useful stack on its own. The exception fires in the browser, but the cause was a malformed payload the server serialized 500 milliseconds earlier during SSR. The client trace tells you what broke; it almost never tells you why. That gap is exactly why unified fingerprinting and session replay matter so much for SvelteKit.

Unified Error Fingerprinting: Connecting the Dots

When a server crash and the client failure it triggers show up as two unrelated issues in your dashboard, you waste hours treating symptoms. The fix is to thread a single identifier through the whole request.

Passing trace IDs from server to client

Generate a trace ID in hooks.server.ts and inject it into the rendered page via transformPageChunk in the handle hook. The client SDK reads it back on init. Now a server exception and the hydration failure it caused share a trace, and you can pivot from one to the other in a click.

// src/hooks.server.ts
export const handle = async ({ event, resolve }) => {
  const traceId = crypto.randomUUID();
  event.locals.traceId = traceId;

  return resolve(event, {
    transformPageChunk: ({ html }) =>
      html.replace('%trace.id%', traceId),
  });
};

Sentry-compatible headers for distributed tracing

The same idea generalizes to distributed tracing. The Sentry SDK propagates sentry-trace and baggage headers across fetch boundaries, so a call from your SvelteKit server to a downstream API shows up as one continuous trace. Custom fingerprinting then lets you group SvelteKit-specific failures—say, every 500 originating in a +layout.server.ts—under one issue instead of a hundred near-identical ones. If issue grouping is eating you alive, our piece on fingerprinting and deduplication is the deeper dive.

Managing Source Maps in a Vite/SvelteKit Pipeline

SvelteKit compiles your .svelte components through Vite, which means the stack trace from a production error points at minified, bundled JavaScript—something like app.a1b2c3.js:1:48201. Useless on its own. Without source maps, every error report is a riddle.

Configuring vite.config.ts for hidden source maps

You want maps generated, but you do not want them sitting on your public CDN where anyone can download your original source. Use hidden source maps: the build produces .map files but omits the //# sourceMappingURL comment that points browsers to them.

// vite.config.ts
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [sveltekit()],
  build: {
    sourcemap: 'hidden',
  },
});

Automated uploads during CI/CD

The pattern is: build, upload the .map files to your error tracker during the same CI step, then delete them before the assets ship to the CDN. Your tracker can deminify on ingest; the public can't snoop. We cover the GitHub Actions version of this end-to-end in the source map docs and the rationale in hiding source maps in production. The general Vite-plus-Sentry recipe in this post applies almost verbatim to SvelteKit since it's the same underlying build.

Dealing with .svelte transformations in stack traces

One SvelteKit-specific gotcha: a single .svelte file becomes multiple compiled chunks (markup, script, styles). A well-configured source map will still resolve a production trace back to the exact line in your Cart.svelte — the difference between "error at app.js:1:48201" and "error at Cart.svelte:42, in handleCheckout" is the difference between a five-minute fix and a five-hour one.

Beyond Logs: Adding Session Replay to SvelteKit

Svelte's reactivity is its superpower and its observability curse. State mutates through fine-grained reactive assignments that never appear in a log file. When a store-driven UI desyncs, the stack trace shows you the crash site but nothing about the sequence of store updates that corrupted the state in the first place.

Replaying the hydration gap

Session replay records the actual DOM as the user experienced it—including the flash of broken interactivity when hydration fails. You see the user click the button, you see nothing happen, and you see the console error fire in context. That "saw what the user saw" quality is the only reliable way to debug a hydration mismatch that produces no server log. Initialize replay with a full session sample rate (replaysSessionSampleRate: 1.0) so you don't miss the first-frame errors that buffer-on-error modes drop.

Privacy-first replay: masking PII in Svelte forms

Replay is powerful, which means it's also a PII liability if you're careless. Mask input fields by default and explicitly unmask only the non-sensitive ones. SvelteKit forms are easy to annotate, and the SDK's masking runs client-side before anything is transmitted. Read more in our replay documentation.

Correlating Core Web Vitals with renders

Because replay captures performance timings alongside the DOM, you can correlate a bad LCP or INP score with a specific Svelte component render. A store update that triggers an expensive re-render shows up as both a vitals regression and a visible jank in the replay—two signals pointing at the same root cause.

Cost-Effective Scaling on Cloudflare

SvelteKit pairs naturally with Cloudflare Pages and Workers, and that's where the economics get interesting. Client-side error noise is high-volume by nature—browser extensions, flaky networks, and ad blockers generate a constant background hum of exceptions. If you're on a per-event pricing plan, that noise is a tax.

Why per-event pricing kills SvelteKit budgets

Consider a SvelteKit site doing real traffic on Cloudflare. A 0.5% client-error rate on a few million page views is tens of thousands of events a month, most of them junk you'll never act on. On a per-event plan, you're paying to ingest noise, which pressures you into sampling—and sampling is how you miss the one real bug. Flat-rate tracking removes that perverse incentive entirely; capture everything, triage at leisure. We laid out the math in flat-rate vs. per-event pricing.

Lightweight SDKs at the edge

Running an error SDK inside a Cloudflare Worker adds overhead on every request, but a well-built edge transport keeps that under one to two milliseconds. The trick is moving the heavy lifting—source map resolution, symbolication, grouping—off the Worker and onto the ingest backend, so your SvelteKit server stays well under the CPU budget. Our guide to debugging Workers in production covers the ctx.waitUntil patterns that keep edge telemetry from getting silently dropped.

SvelteKit's split-brain architecture is a strength—you get blazing SSR and snappy client navigation from one codebase—but it doubles the surface area where things break. Track both sides, thread a trace ID through the middle, keep your source maps current, and add replay for the failures logs can't explain. Stop guessing why your hydration is failing: set up GlitchReplay for Sentry-compatible error tracking and full session replays on one flat monthly rate.

Stop watching your error bill spike.

GlitchReplay is Sentry-SDK compatible, includes session replay and security signals, and never charges per event. Free to start, five minutes to first event.