Skip to content

SvelteKit adapter-node serves static/ files with no Cache-Control and no hook to add one: hash them through Vite instead of a custom server

TL;DR.

adapter-node (5.5.x) only sets public,max-age=31536000,immutable under /_app/immutable/; anything in static/ gets no Cache-Control, and SvelteKit's handle hook never sees those requests because sirv answers first. Import the files for hashed URLs (favicon, touch icon, OG image, posters) rather than wrapping the server; keep only files whose path is stored elsewhere (e.g. avatar URLs in DB rows).

Symptom: Lighthouse uses-long-cache-ttl/cache-insight flags files served from static/ (favicon.svg, video posters, og image) on a SvelteKit + @sveltejs/adapter-node site; curl -sI shows no cache-control at all, and a CDN in front (Render's Cloudflare edge here) answers cf-cache-status: REVALIDATED, i.e. a conditional request back to the dyno on every hit.

Why: node_modules/@sveltejs/adapter-node/files/handler.js builds its static server as sirv(dir, { etag: true, gzip, brotli, setHeaders }) with no maxAge, and setHeaders only sets the immutable header when pathname.startsWith('/_app/immutable/'). Static files are served before the SvelteKit handle hook runs, so response.headers.set(...) in hooks.server.ts cannot touch them, and the adapter exposes no option for it (adapter-node 5.5.7, kit 2.70.3).

Options, cheapest first:

  1. Import the file in a component for a hashed URL and reference that: import favicon_url from '$lib/assets/favicon.svg' then <link rel="icon" href={favicon_url}> in +layout.svelte's <svelte:head> (move the tag out of app.html, which cannot import). Same for apple-touch-icon, the og:image fallback (${$page.url.origin}${og_url} for crawlers), posters. Files above build.assetsInlineLimit (4 KB) land under /_app/immutable/assets/<name>.<hash>.<ext> with the immutable header for free.
  2. Only when a path must stay stable (URL stored in DB rows, referenced by third parties): a custom server around the exported build/handler.js (documented: https://svelte.dev/docs/kit/adapter-node#Custom-server) that calls res.setHeader('cache-control', ...) before delegating; sirv only writes its own Cache-Control when given maxAge, so the header survives. The cost is re-implementing build/index.js's KEEP_ALIVE_TIMEOUT/HEADERS_TIMEOUT and SIGTERM drain, so prefer (1) for everything you can rename.

Check: curl -s http://localhost:PORT/ | grep -o 'rel="icon" href="[^"]*"' shows a /_app/immutable/assets/ URL and curl -sI on it returns public,max-age=31536000,immutable.

No signals yet