Browse all prompts
Make a SvelteKit site load instantly
Measure where the time actually goes, then prerender everything that can be static, cache the rest at the edge, fill regional caches before visitors arrive, and make navigation instant, on Vercel, Cloudflare, Netlify or a Node server behind a CDN.
Make this SvelteKit site as fast as it can be on its host: **[site URL, and the host: Vercel, Cloudflare, Netlify, a Node server behind a CDN, or a static host]** The goal is a site that feels as if it were on the visitor's disk: pages that paint in well under a second on a cold visit, and navigation that feels instant. Most of that comes from moving work to build time and serving it from the edge nearest the visitor. Code tweaks come last and matter least. Work in the order below, measure before and after each step, and tell me the numbers, not your impressions. Framework and platform behaviour changes often. Check current SvelteKit, adapter and host documentation before relying on a detail here, and read the adapter's source when the docs are silent: it is short and it is the truth. ## 1. Measure first, from where the visitors are Before changing anything, build a baseline table: route, response type, TTFB, FCP, LCP, cache status and serving region. Cover every kind of route: home, a listing, a detail page, a page with a query string, an API response, and anything embedded in an iframe. - Request each URL twice with `curl -w '%{time_starttransfer}'` and record the cache header both times: `x-vercel-cache` and `x-vercel-id` on Vercel, `cf-cache-status` and the `cf-ray` suffix on Cloudflare, `Cache-Status` on Netlify, `x-cache` with `x-amz-cf-pop` on CloudFront. A slow first request followed by a fast second one is a cache that is working but cold. Slow every time means nothing is being cached. - Separate the three costs you will find: a function or server running per request, a CDN miss that travels back to origin storage, and payload or render time in the browser. Each has a different fix, so don't blend them into one number. - Measure in a real browser too, with a fresh profile per run: Lighthouse, or Playwright reading `PerformanceNavigationTiming` and `largest-contentful-paint` entries. Test from more than one region if you can (WebPageTest, or a machine elsewhere). A site measured only from next to its origin region looks faster than it is. - If the site has real traffic, read the field data too (PageSpeed Insights or CrUX). Lab numbers find problems; field numbers tell you whether visitors feel them. ## 2. Decide, per route, what can be static List every route and what its response actually depends on: path parameters, query parameters, cookies, headers, time, or a live service. Then sort each into one of three classes: - **Static**: the same bytes for everyone until the next deploy. Prerender it. - **Cacheable on demand**: shared by everyone but not enumerable in advance, such as a filtered listing or a search-free query. Render it once and cache it at the edge until the next deploy. - **Truly dynamic**: personalised, live, or a mutation. Keep it dynamic, make it fast, and keep it off the critical path of static pages. Most content sites end up almost entirely in the first class. Anything that reads cookies or auth headers in a shared layout pushes every page into the third, so find and remove those reads first. ## 3. Prerender everything reachable without a query - `export const prerender = true` on every static route. For dynamic segments, export `entries()` listing every value, including pages nothing links to. The crawler only finds what is linked. - A prerendered page with a server `load` also emits `__data.json`, so client-side navigation to it loads a static file too. That is the point: check it exists in the build output. - Static files lose anything set with `setHeaders`. Security headers, `Link` headers and content types must be declared again in the host's configuration (`vercel.json` headers, a `_headers` file, or the proxy). Add a test that compares the declared headers with the ones the code sets, so they cannot drift apart. - A route that is both a page and an endpoint on one path (content negotiation by `Accept`) cannot be prerendered. Build the page at an internal path and have the edge serve it when `Accept` starts with `text/html`. Keep a `reroute` hook for client navigation, and leave protocol traffic untouched. - Prerendering fails loudly on broken links and missing anchor ids (`handleHttpError`, `handleMissingId`). Fix real breakage; relax the check only for content you don't control, such as embedded third-party markup, and scope that relaxation by path. ### Query strings are the trap Static hosts serve the static file whatever query arrives with it. `/products?sort=price` quietly returns the unsorted page, and nothing errors. SvelteKit also refuses to let a prerendered `load` read `url.searchParams`. Choose deliberately: - **Route query-bearing requests to the server.** Add a host rule, evaluated before static files are served, that sends a request carrying one of the page's parameters to SSR. The bare URL stays static. One rule per parameter the page understands; unknown parameters such as `utm_source` fall through to the static page, which is correct. Include the page's `__data.json` in the same rule, because client navigations carry the query there too. Hold each rule list to the parser that reads those parameters with a test, so a new filter cannot be silently answered by the default page. - **Or handle the query in the browser**, when there are few enough states to ship them all with the page. That is simpler to host, but a query URL flashes the default view before it corrects itself. - **Or move finite states into the path**: `/products/sort/price` can be prerendered. That changes public URLs, so redirect the old ones. When the same `load` serves both, read the query only outside prerendering: `const query = building ? new URLSearchParams() : url.searchParams`. At build time, also read `url.pathname`. That subscribes the prerendered data to the URL, so a client navigation that adds a query fetches fresh data instead of keeping the default view's. Without it, the page silently keeps the default data. ## 4. Cache everything else at the edge until the next deploy - A server response is only cached at the edge when you say so. Use the host's CDN header rather than a long browser `max-age`, so browsers still revalidate after a deploy: `Vercel-CDN-Cache-Control`, `Cloudflare-CDN-Cache-Control` or `CDN-Cache-Control`, `Netlify-CDN-Cache-Control` (add `durable` for Netlify's shared cache), or `s-maxage` behind a generic CDN. - Only cache what is identical for everyone. Never cache responses that read cookies or auth, live search results, or errors. If a URL answers differently by a header, cache nothing there or vary on that header explicitly. - Know how the host invalidates. Vercel and Netlify purge on deploy. Cloudflare's cache of Worker responses and most CDNs in front of Node do not, so purge by tag or prefix in the deploy pipeline, or put the deploy's version in the cache key. - Markdown twins, JSON APIs, feeds and sitemaps are easy to forget. They are usually just as static as the pages. ## 5. Beat the cold miss A CDN fills its cache region by region, and only when someone asks. After every deploy, the first visitor in each region waits for origin storage on every file, which can cost hundreds of milliseconds per file far from the origin. This is often the single largest remaining delay once everything is static. - Use what the host offers first: Cloudflare Tiered Cache or Cache Reserve, Netlify's `durable` cache, CloudFront Origin Shield. - Then warm it. At build time, write a list of every static file (pages, `__data.json`, JS, CSS, fonts, the images pages actually load). After a deploy, fetch that list through each important region's cache. On Vercel that means a small function pinned to each region and run by cron: a function's requests to the site go through its own region's cache. Elsewhere, CI runners or workers in several regions can do it. Decide whether a region is already warm by sampling a few listed files, not by one marker file anyone can request. - Prove it: request a file you have not touched since the deploy, from a warmed region, and expect a cache HIT on the first request. ## 6. Make navigation instant - **Code:** preload it before the click. Add `data-sveltekit-preload-code="viewport"` on dense lists of links to one route, so its code arrives once when the first link is on screen, and keep `preload-data="hover"` globally. SvelteKit keeps only one data preload at a time, so preloading every visible link's data is wasted. - **Late discoveries:** find anything loaded only after hydration and hint it earlier: dynamic imports inside `load`, embedded iframes, and their chunks. At build time, resolve those modules through Vite's manifest and emit `modulepreload` links in the right document's `<head>`. - **Iframes** are a second full page load and are easily the slowest thing on a page. Make the frame's default document a static file, never lazy-load a frame that is the main content, reserve its height so it cannot shift the layout, and warm its document on intent. - **Browser support:** check it for every hint. `<link rel="prefetch">` is not supported in Safari: test `relList.supports('prefetch')` before adding one, or Safari's inspector shows a request that never finishes. Speculation Rules only help full-document navigations, and SvelteKit's router handles same-site clicks itself, so reserve them for links that deliberately do a full load. ## 7. Think hard before adding a service worker A worker that serves pages from disk makes repeat navigation instant, and it is the riskiest step here by far. Do the rest first. If the site is already fast from the CDN, skip it. The failure is subtle. A worker from one deploy keeps HTML or `__data.json` from another, the page asks for script chunks the new deploy has deleted, and it never hydrates: buttons stay disabled and demos stop responding. It only shows in browsers that visited before a deploy, so a fresh test browser never sees it. If you add one anyway: - Precache only the build's hashed JS and CSS, which are correct in any deploy by name. Cache prerendered pages and data only while `/_app/version.json` still matches the worker's `version`. Send query-driven URLs, APIs, anything personalised and anything that isn't a GET to the network. - Keep one cache per `version`, and don't delete the old one while pages from that deploy are still open and may ask for their chunks. - Plan the exit before you ship: a worker at the same URL that deletes every cache, unregisters and reloads the pages it controlled. Removing the file alone does not work, because browsers keep the old worker when its update fails. - Test a real deploy switch, not just a fresh load: visit, deploy a build whose chunk hashes change, then visit again, in Chrome and Safari. - Safari evicts site storage after about a week without a visit, so the worker is an accelerator, never a requirement. ## 8. Trim what the browser has to do Only after the steps above: - **Build time:** move work to the build that currently runs per visit, such as syntax highlighting, Markdown rendering and search indexes. A client-side highlighter can easily be the largest script on a page. Rendering on the server removes it, but watch the markup size, because the output ships twice (in the HTML and in the hydration data). Unwrap default-colour tokens, and turn repeated inline styles into classes. - **CSS:** inline small stylesheets (`kit.inlineStyleThreshold`) to remove render-blocking requests. Measure the trade-off, since inlined CSS repeats on every full load. - **Fonts:** subset them to the characters the site uses, and preload only those actually used above the fold. - **Images:** serve AVIF and WebP with explicit sizes (`@sveltejs/enhanced-img`). Only the real LCP image gets eager loading and `fetchpriority="high"`. - **Content pages:** `csr = false` on pages with no interactivity ships no JavaScript at all. - **Hydration order:** give hydration preloads low priority so fonts and the LCP image go first. ## 9. Verify like a sceptic - Keep a test that replays the host's routing configuration against sample URLs: bare URLs reach static files; each query parameter, and anything that is not a page, reaches the server. Build output is data, so assert on it. - After deploying, re-run the baseline table from step 1, cold and warm, in at least two regions. - Open the site in Chrome **and** Safari, then read each browser's network panel. Look for requests that never finish, duplicate document loads, and hints the browser ignored. - Check that nothing regressed: every query URL still returns what it did before, error pages keep their status codes, the security headers of static files are still there, and non-browser clients of any shared URL still get what they expect. Finish with a before-and-after table for every route type (TTFB, FCP, LCP, cache status, cold and warm), what each change bought, what you chose not to do and why, and anything still slow with its cause. ## Platform notes **Vercel.** Static files are served without invoking a function, and the query string is ignored. The adapter writes its own routing to `.vercel/output/config.json`, where its rewrites for prerendered pages come before the filesystem check and end route matching. Rules that must beat them go at the very top, which can mean post-processing that file in a small adapter wrapper. `vercel.json` redirects and headers also apply to static files. Routes with different `config` (for example `maxDuration`) are built into different functions, and a function can only render routes it contains. `Vercel-CDN-Cache-Control` caches function responses until the next deploy. The CDN cache is per region and filled lazily; there is no built-in prewarming. There are no 103 Early Hints. **Cloudflare.** Static assets are served before the Worker, with queries ignored. Use `run_worker_first` (Workers) or `_worker.js` with `env.ASSETS.fetch` (Pages) when a query must reach SSR. Use `_headers` for static headers, `Cloudflare-CDN-Cache-Control` or the Cache API for SSR responses, and turn on Tiered Cache and Cache Reserve against cold misses. Cached Worker responses are not purged on deploy. Early Hints can be sent from `Link` headers. Worker cold starts are small; Smart Placement moves execution next to your backend when the database is the bottleneck. **Netlify.** Prerendered files bypass functions, and queries are ignored. `_redirects` or `netlify.toml` rules with query conditions and `force` can send query-bearing requests to a function. `Netlify-CDN-Cache-Control: public, s-maxage=31536000, durable` shares one render across all edges, and `Netlify-Vary` controls which query parameters enter the cache key. Atomic deploys invalidate the cache. Pin functions to the region nearest your data. **Node behind a CDN.** Nothing is cached until you say so: send `s-maxage` (with `stale-while-revalidate` where stale is acceptable) and make sure the CDN's cache key treats query strings the way your routes do. Serve the prerendered output and `_app/immutable/` from the CDN or object storage, not through Node. Compression, HTTP/3 and Early Hints come from the proxy or CDN, not Node. Enable origin shielding, and purge or version the cache in the deploy pipeline. **Static hosts (`adapter-static`).** Everything must be prerendered or handled in the browser, so query-driven states need the browser or path-based approach from step 3, and headers come from the host's configuration.