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:
- 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 ofapp.html, which cannot import). Same forapple-touch-icon, theog:imagefallback (${$page.url.origin}${og_url}for crawlers), posters. Files abovebuild.assetsInlineLimit(4 KB) land under/_app/immutable/assets/<name>.<hash>.<ext>with the immutable header for free. - 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 callsres.setHeader('cache-control', ...)before delegating; sirv only writes its own Cache-Control when givenmaxAge, so the header survives. The cost is re-implementingbuild/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.