Caching responses in SvelteKit: best practices explained
Response caching is one of the highest-impact optimizations we can implement in web applications, yet it's often overlooked or misconfigured. When a user's browser or a CDN can serve cached content instead of hitting your origin server, you eliminate network latency, reduce server load, and deliver pages in milliseconds rather than seconds. This guide walks through the practical patterns we've used to cache responses effectively in SvelteKit, from setting headers in load functions to configuring CDN strategies for prerendered pages.
Using setHeaders in load functions
The foundation of response caching in SvelteKit is the setHeaders function available in both server-side and universal load functions. This function lets us control what headers the HTTP response includes, and it's the mechanism through which we communicate caching intent to browsers and CDNs.
A typical scenario: we're building a product listing page that changes infrequently. The page is expensive to render because it queries a database and performs calculations, but the content doesn't update more than once per hour. We can cache it aggressively.
// src/routes/products/+page.server.js
export async function load({ setHeaders }) {
const products = await fetchProductsFromDatabase();
// Cache for 1 hour
setHeaders({
'cache-control': 'public, max-age=3600'
});
return { products };
}When a browser receives this response, it stores it for 3,600 seconds. Any subsequent navigation to that page within the hour serves the cached version without making a network request. If you're running behind a CDN like Cloudflare or AWS CloudFront, the CDN also respects this header and caches the page at its edge locations, serving it to other users without querying your origin.
The setHeaders function accepts any HTTP headers you need to set. Beyond cache-control, you might set custom headers for logging or application-specific logic, but cache-control is the primary lever for caching behavior.
Cache-Control for public pages
The Cache-Control header is a negotiation between your application, browsers, and CDNs. Understanding its directives prevents subtle bugs and wasted cache capacity.
The public directive signals that the response is not user-specific and can be cached by any intermediary. The max-age directive specifies seconds until the cache is considered stale. There's also s-maxage, which applies only to shared caches (CDNs and proxies), allowing you to cache longer at the edge than in browsers.
In an e-commerce application, a category page listing products might be safe to cache publicly, but a user's dashboard showing their order history is not.
// src/routes/category/[slug]/+page.server.js
export async function load({ params, setHeaders }) {
const category = await db.categories.findBySlug(params.slug);
const products = await db.products.where({ categoryId: category.id });
// Public pages: cache in browser and CDN
setHeaders({
'cache-control': 'public, max-age=3600, s-maxage=86400'
});
return { category, products };
}Here, browsers cache for 1 hour, but the CDN caches for a full day. This pattern reduces origin traffic significantly because even after browsers invalidate their cache, the CDN continues serving the page to other users.
For pages that should not be cached at all—like login pages or dynamic forms—use no-cache or no-store. The difference: no-cache allows the cache to store the response but requires revalidation with the server before use, while no-store prevents caching entirely.
// src/routes/login/+page.server.js
export async function load({ setHeaders }) {
setHeaders({
'cache-control': 'no-store'
});
return {};
}Vary on cookies carefully
The Vary header tells caches that a response depends on request headers and should be cached separately based on their values. The most common use case is varying on the Cookie header for personalized content.
Consider a dashboard that shows different data depending on whether the user is logged in and which permissions they have:
// src/routes/dashboard/+page.server.js
export async function load({ cookies, setHeaders }) {
const sessionId = cookies.get('session_id');
if (!sessionId) {
setHeaders({
'cache-control': 'public, max-age=3600',
'vary': 'Cookie'
});
return { isLoggedIn: false };
}
const user = await verifySession(sessionId);
// Cache per user session
setHeaders({
'cache-control': 'private, max-age=600',
'vary': 'Cookie'
});
return { isLoggedIn: true, user };
}By setting Vary: Cookie, we're telling caches: "This response depends on the Cookie header. Store separate cache entries for requests with different cookies." Without this header, a cache might serve a logged-in user's dashboard to an anonymous visitor or vice versa—a serious security issue.
The caveat: vary on cookies is expensive for CDNs. If a thousand users each have unique session cookies, the CDN maintains a thousand separate cache entries for the same URL. For such cases, consider either moving personalization to the client side (fetch via API after page load) or using a private cache strategy that relies only on browser caching, not CDN caching.
A pragmatic approach for member-only content:
// src/routes/member/profile/+page.server.js
export async function load({ cookies, setHeaders }) {
const sessionId = cookies.get('session_id');
const user = await verifySession(sessionId);
if (!user) {
throw redirect(303, '/login');
}
// No CDN cache, only browser cache
setHeaders({
'cache-control': 'private, max-age=600'
});
return { user };
}The private directive means only the browser caches it; shared caches (CDNs) skip it entirely. This avoids the per-user explosion in cache size.
CDN caching for prerendered pages
SvelteKit's prerendering feature generates HTML at build time for routes you specify. Prerendered pages are static files, perfect for aggressive CDN caching.
When you configure prerender in your svelte.config.js, SvelteKit generates HTML files and outputs them to a static directory. Your build process copies these to your hosting provider, often a CDN like Vercel Edge Network or AWS CloudFront.
// svelte.config.js
export default {
kit: {
prerender: {
entries: [
'*',
'/sitemap.xml',
'/rss.xml'
],
crawl: true,
handleHttpError: 'warn'
}
}
};For prerendered pages, you want the CDN to cache indefinitely, only invalidating when you deploy a new build:
// src/routes/+page.server.js
export const prerender = true;
export async function load({ setHeaders }) {
setHeaders({
'cache-control': 'public, max-age=31536000, immutable'
});
return { /* data */ };
}The immutable directive tells browsers and CDNs that the content will never change as long as the URL remains the same. A year of caching is practical because prerendered content is only updated via deployment.
On platforms like Vercel, prerendered pages are automatically deployed to the CDN edge. On AWS, you might push to an S3 bucket fronted by CloudFront. The deployment platform handles the mechanics, but your headers must align with the deployment strategy.
A common pattern in our teams: prerender the homepage and landing pages, cache them for a year at the edge, and rely on browser cache busting via content hashes in asset filenames (which SvelteKit does automatically) to update styles and scripts.
Common mistakes
- Forgetting to set cache headers on dynamic routes: If you don't call
setHeaders, SvelteKit defaults to no caching. Every request hits the server. Always be explicit about your intent, even if it's "don't cache." - Caching user-specific data publicly: A page showing a user's personal information cached with
public, max-age=3600is a data leak. Verify that cached content is truly public before using thepublicdirective. - Setting Vary: Cookie without understanding the cost: For high-traffic sites, varying on cookies can create cache fragmentation. Consider alternatives like client-side data fetching or private caching.
- Not considering s-maxage in CDN contexts: If you deploy to a CDN but only set
max-age, the CDN uses the same TTL as browsers. Separately tunings-maxagelets you keep origin requests lower while providing fresh content to users. - Mixing prerendered and dynamic content without clear headers: Prerendered pages should have aggressive cache headers. Dynamic pages should be explicit about their TTL. Confusion here leads to stale content or excessive origin load.
Key takeaways
- Use
setHeadersin load functions to control HTTP response caching via theCache-Controlheader. - Distinguish between
max-age(browser cache) ands-maxage(CDN cache) to optimize for both user experience and origin load. - Set
Vary: Cookieonly when response content depends on cookies; preferprivatecaching for user-specific pages to avoid CDN cache fragmentation. - Prerendered pages are static assets; cache them aggressively with
max-age=31536000, immutableto minimize origin requests. - Always be explicit: if you don't call
setHeaders, the default is no caching, which is safe but potentially wasteful. - Test cache behavior in development using browser DevTools and in staging using curl headers to verify CDN behavior before deploying to production.






Comments
Loading comments…