All articles
169 articles · updated weekly See our Tools
All articles
Tutorials

1.6s TBT in Production: What Actually Fixed It on Quick Tools with SvelteKit

A real performance optimization case in SvelteKit: manualChunks, dynamic imports, service worker, and the clients.claim() bug that breaks chunks after deploy.

1.6s TBT in Production: What Actually Fixed It on Quick Tools with SvelteKit
COVER · Tutorials

Lighthouse returned 59/100 on Performance, TBT between 1,064ms and 1,648ms, main bundle at 1.43MB. We had over 70 tools running, each with their specific dependencies — highlight.js, marked, ajv, qr-code-styling, js-yaml — all in the main bundle, downloaded and parsed on every visit, regardless of which tool the user wanted.

This post documents the eight optimization phases we did: what worked, what surprised us, and a service worker bug that cost us several hours of debugging and that most performance articles never mention.


The diagnosis: why the bundle was so large

The problem wasn't one thing. It was the sum of several category errors.

Vite does automatic code splitting, but for third-party dependencies it tends to create a single vendor chunk with everything together. Result: a user accessing the text converter was downloading highlight.js. Someone using the calculator was downloading js-yaml. Nobody needed any of that, but the browser needed to parse it before any interaction — hence the high TBT.

The second problem was that even with dynamic import() in the code, some critical imports were still static in route files. Static imports mean the browser needs the module before rendering the page — no negotiation.


manualChunks + dynamic imports: the foundation

The first phase was configuring manualChunks in vite.config.ts to separate each heavy library into its own file:

manualChunks(id) {
  if (!id.includes('node_modules')) return;
  if (id.includes('/highlight.js/'))    return 'vendor-highlight';
  if (id.includes('/marked/'))          return 'vendor-markdown';
  if (id.includes('/ajv/'))             return 'vendor-ajv';
  if (id.includes('/jsonrepair/'))      return 'vendor-jsonrepair';
  if (id.includes('/qr-code-styling/')) return 'vendor-qr';
  if (id.includes('/js-yaml/'))         return 'vendor-yaml';
  if (id.includes('/fuse.js/'))         return 'vendor-search';
  if (id.includes('/lucide-svelte/') || id.includes('/@iconify/')) return 'vendor-icons';
  if (id.includes('/bits-ui/') || id.includes('/svelte-sonner/')) return 'vendor-ui';
}

Just this changes the behavior: a user accessing the JSON validator loads vendor-ajv and vendor-jsonrepair. Someone using the QR code generator loads vendor-qr. The other chunks aren't even requested.

The second benefit is more surgical cache invalidation. When the JSON validator logic changes, only vendor-ajv and the tool's chunk are invalidated. The vendor-highlight, vendor-yaml, and the rest stay in cache — the user doesn't download anything that didn't change.

The second part was converting static imports of heavy libraries to dynamic imports in route files:

// before — forces download before rendering
import { marked } from 'marked';

// after — only loads when the tool actually needs it
const { marked } = await import('marked');

The SSG nuance most people miss

There's an important detail for those using SvelteKit with adapter-static that isn't documented obviously: load in +page.ts only executes at build time.

On the client, SvelteKit uses the pre-rendered __data.json — the load never re-executes in the browser. This means dynamic imports inside the load function have no cost in the client-side bundle. The output is already serialized in the JSON; the markdown, YAML, or whatever library chunks are never fetched by the client.

The practical implication: with SSG, you can do await import('heavy-lib') inside the load without guilt. The parse cost of that library never shows up in the user's TBT.


Prefetch and service worker with cache-first

With code splitting in place, navigation between tools became lighter — but there was still noticeable latency on the first access to each tool, because the chunk needed to be downloaded.

The solution was adding data-sveltekit-preload-code="viewport" to the <body>:

<body data-sveltekit-preload-code="viewport">

With this, when a tool link enters the viewport — whether on desktop or mobile — SvelteKit already starts fetching that tool's JS chunk in the background. When the user clicks, the chunk is probably already in cache. Navigation feels instant.

The service worker complements with cache-first for hashed assets:

// Cache-first for hashed assets (build chunks — never change)
if (PRECACHE.includes(url.pathname)) {
  const cached = await cache.match(event.request);
  if (cached) return cached;
}
// Network-first for HTML — can update with each deploy

A file like vendor-highlight-abc123.js has the hash in the name and never changes. If it's in cache, serve from cache directly — zero network. On subsequent visits, practically all JS and CSS is already in the browser cache.


The bug most articles don't tell: clients.claim() and chunk 404 after deploy

After the first round of optimizations, TBT dropped to 610ms. But errors started appearing in the console for production users:

GET https://quickeasy.tools/_app/immutable/chunks/BOiooyxC.js 404
TypeError: Failed to fetch dynamically imported module: .../DWxm8pPm.js

Users with cached HTML from a previous deploy were trying to load chunks with old hashes — files that no longer existed after the new deploy. Tools were silently failing to open.

The root cause was a combination of two service worker behaviors that, together, form a trap:

  • skipWaiting() activates the new SW immediately after installation
  • clients.claim() makes the new SW take control of all open tabs — including tabs that still have old HTML with references to old chunk hashes

The new SW doesn't have the old chunks in cache. Every dynamic import in those tabs results in a 404.

The fix was removing clients.claim() from the activate handler:

self.addEventListener('activate', (event) => {
  event.waitUntil(evictOldCaches());
  // No clients.claim() — open tabs continue using the old SW until navigation
});

Without clients.claim(), open tabs continue using the previous SW (which still has the old chunks in cache) until the user navigates. New tabs get the new SW immediately. No 404s.

As a safety net for edge cases — a tab opened exactly during the deploy window, or HTML cached directly in the browser without going through the SW — we added a hooks.client.ts:

export const handleError: HandleClientError = ({ error }) => {
  const msg = error instanceof Error ? error.message : String(error);

  const isChunkLoadError =
    msg.includes('Failed to fetch dynamically imported module') ||
    msg.includes('Importing a module script failed');

  if (isChunkLoadError) {
    const key = 'qt-chunk-reload';
    if (typeof sessionStorage !== 'undefined' && !sessionStorage.getItem(key)) {
      sessionStorage.setItem(key, '1');
      window.location.reload();
    }
  }

  return { message: msg || 'Unexpected error' };
};

The guard with sessionStorage prevents an infinite loop in case the network itself is broken.


Removing from the critical path what doesn't need to be there

Even with code splitting working, there were two components doing heavy work on every page load.

Search index. The layout component was fetching the search JSON and building a Fuse.js index with 71 tools on every mount — even for users who would never touch the search box. The fix was making SearchBox self-contained and lazy:

async function ensureIndex() {
  if (indexLoaded || indexLoading) return;
  indexLoading = true;
  const [{ default: Fuse }, res] = await Promise.all([
    import('fuse.js'),
    fetch(`/search-index-${lang}.json`)
  ]);
  fuse = new Fuse(await res.json(), { keys: [...], threshold: 0.35 });
  indexLoaded = true;
}

async function handleFocus() {
  await ensureIndex(); // only fires on first focus or Ctrl+K
}

Fuse.js and the search JSON were completely removed from the critical path. Now they only load when the user actually opens the search.

ThemeCustomizer. The theme customization drawer was statically imported in +layout.svelte, adding weight to the layout chunk — downloaded on every page. 99% of users never open this drawer. The solution was to import dynamically, tied to the open store:

let ThemeCustomizer: Component | null = $state(null);

$effect(() => {
  if ($customizerOpen && !ThemeCustomizer) {
    import('./ThemeCustomizer.svelte').then(m => {
      ThemeCustomizer = m.default;
    });
  }
});

The component doesn't exist in the initial bundle. It's loaded only the first time the user opens the customizer.


CSS that blocks without looking like it blocks

Two CSS problems were contributing to TBT without appearing obvious in the profiler.

The first was transition: all 0.2s ease in some components. transition-all includes properties like height, width, and top — all of which trigger layout reflow on the main thread. The fix was simple:

/* before — goes through the layout thread */
transition: all 0.2s ease;

/* after — only compositing, goes directly to the GPU */
transition: opacity 0.2s ease, transform 0.2s ease;

The second was content-visibility: auto on the tools grid. The homepage was rendering all 71 cards before completing the first paint — including the 50+ outside the viewport:

.tool-card-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 180px;
}

content-visibility: auto tells the browser it can skip layout and paint of items outside the viewport. contain-intrinsic-size provides a stable size placeholder so the scrollbar doesn't jump during scrolling. The browser went from doing layout of ~20 cards, not 71.


What actually moved the needle

After both rounds, these were the techniques with real impact on TBT and Speed Index:

Technique What it solved
manualChunks + dynamic imports Eliminated heavy libs from the initial bundle
Viewport prefetching Navigation without perceived latency on mobile
Service worker cache-first Near-zero latency on repeat visits
Removing clients.claim() Fixed silent chunk 404s after deploys
hooks.client.ts as fallback Safety net for deploy edge cases
Lazy search index + Fuse.js on-demand Removed parsing of 71 tools from initial load
ThemeCustomizer with dynamic import Removed heavy component from layout chunk
Composited-only animations Eliminated layout reflow in transitions
content-visibility: auto Browser skips render of 50+ cards outside viewport

The pattern that emerged throughout the entire process: defer everything that's not needed for the current view. The first paint should cost as little as possible; everything else can wait until the user actually needs it.

In SvelteKit with adapter-static and many features, performance has no silver bullet. It's independent layers — bundle, cache, CSS, service worker — and each can be the bottleneck. The right place to start is Lighthouse with CPU throttling enabled, not the "feels fast" feeling on localhost.


Note: the editorial content ends here. What follows is a note about the project described in the post.


About Quick Tools

Quick Tools is the platform described in this post — over 70 tools for devs, finance, and productivity, running as a static site with SvelteKit. Visit at quickeasy.tools.

RD
Author
Rafael Duarte
Backend developer with a background in fintech and B2B SaaS — worked on teams that scaled APIs from zero to millions of requests. Carries enough production scars to hold strong opinions on tools, patterns and architecture decisions. Not an academic: he read the UUID RFC when he had to choose between v4 and v7 for a write-heavy table.
View profile