Short answer
Remix does not inject a default Cache-Control header on GET loader responses — not on Vercel, not anywhere else. If you want caching, you must return the header yourself, either from the loader or from the route's headers export. There are no adapter-specific flags in Remix that turn on automatic header injection. Caching is an explicit, per-route decision by design, because Remix can't know whether your data is public, per-user, or time-sensitive.
Confirmed facts
- Loader responses carry only the headers you set:
return json(data, { headers: { 'Cache-Control': 'public, max-age=60' } }). - If the route defines a
headers export, it controls the final document headers. It receives loaderHeaders and must forward them — defining headers() without merging loaderHeaders silently drops the loader's Cache-Control. This is the most common cause of "my header disappeared." - Thrown responses (
throw json(...), throw redirect(...)) do not inherit success-path headers; set headers on the thrown Response itself. - Asset caching (fingerprinted
/build files) is configured separately at the host/CDN layer and is unrelated to loader response caching.
Likely explanations for what you're seeing
Separating what's Remix's fault from what's the platform's:
- Vercel adapter: Remix sets nothing by default, so with no explicit header you'll see whatever Vercel's edge applies to function responses — which can differ from what you returned. If you do return a header and it's still wrong in production, check
vercel.json for a headers rule overriding the route, and check whether the response sets Set-Cookie (session access often causes platforms to rewrite Cache-Control to private/no-store). - Cloudflare Workers: the claim that the adapter strips "unknown headers" unless listed in
wrangler.toml compatibility flags does not match established Workers behavior — compatibility flags gate runtime API features, not response header allowlists. Flag this premise for review. More likely causes are the headers export dropping loaderHeaders, or a Worker/Pages Functions layer or Cache API config modifying the response after Remix returns it. - Version sensitivity: under Remix v2 single-fetch, loader data responses use turbo-stream semantics and header handling differs between document and data requests; newer releases also deprecate
json() in favor of returning raw Response objects. Verify against your installed version.
How to isolate the layer at fault
Run the same app locally with the Express adapter and compare against production:
curl -si https://your-app.example.com/route | grep -i cache-control
curl -si http://localhost:3000/route | grep -i cache-control
If local is correct and production is wrong, the platform edge is rewriting headers — Remix is not involved. If both are wrong, inspect the route: does a headers export exist, and does it return loaderHeaders (or merge them) rather than replacing them? Also test the loader without touching the session to rule out Set-Cookie-triggered rewriting.
One caution
Before adding public caching, confirm the response doesn't vary per user. If it depends on cookies or auth, use private or add Vary: Cookie — otherwise a shared CDN cache can serve one user's data to another.