|Docs

Speed Up a Global App with Cache-Control Headers and a CDN

cachingcdnperformancenetworking

A user in Singapore requesting a page from a service in US West waits on every round trip. Caching fixes most of that wait. The browser caches what it already downloaded, and a CDN caches responses at edge locations near your users, so repeat requests don't cross the ocean.

Both layers are driven by one header: Cache-Control, which your service sets on each response to declare who may cache it and for how long. Get that right first: a CDN with wrong headers either caches nothing (no speedup) or caches too much (users see each other's data).

How Cache-Control works

Cache-Control is a comma-separated list of directives. The ones that matter in practice:

DirectiveMeaning
publicAny cache (browser or CDN) may store the response.
privateOnly the end user's browser may store it. CDNs must not.
no-storeNobody caches it. Every request hits your service.
max-age=NThe response is fresh for N seconds.
s-maxage=NFreshness for shared caches (CDNs) only. Overrides max-age at the edge.
immutableThe content at this URL never changes, so the browser skips revalidation.
stale-while-revalidate=NFor N seconds after expiry, serve the stale copy instantly and refresh in the background.
stale-if-error=NFor N seconds after expiry, serve the stale copy if the origin returns an error.

A response with no Cache-Control header gets whatever each client decides: browsers apply heuristics and CDNs apply their own defaults. Set headers explicitly.

Choose a policy per response type

Different responses need different lifetimes: four categories cover most apps.

Hashed static assets: cache forever

Bundlers like Vite, webpack, and esbuild write asset filenames with a content hash (app.4f8a2c.js). When the content changes, the filename changes, so the old URL never goes stale. Cache these as long as possible:

Cache-Control: public, max-age=31536000, immutable

That is one year. immutable tells browsers to skip revalidation entirely.

HTML: short TTL with stale-while-revalidate

HTML URLs are stable (/, /pricing), so you cannot cache them forever. A short freshness window plus stale-while-revalidate gives you edge-speed responses without stale pages:

Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=300

Browsers revalidate every time (max-age=0), the CDN serves it for 60 seconds, and for 5 minutes after that it serves the cached copy instantly while fetching a fresh one in the background.

Public API responses: short shared cache

JSON that is the same for every user (a public catalog, a status endpoint) can take a short s-maxage so bursts of traffic collapse into one origin request:

Cache-Control: public, s-maxage=30, stale-while-revalidate=60

Personalized or sensitive responses: keep them out of shared caches

Anything that depends on who is asking must never land in a CDN cache:

Cache-Control: private, no-store

Use this for account pages, dashboards, and any response after login. Railway's CDN adds two protections of its own: requests with an Authorization header bypass the cache, and responses with a Set-Cookie header are never cached.

Complete example: Express with per-route cache headers

This server applies all four policies. Create a project and install the dependency:

mkdir cache-demo && cd cache-demo
npm init -y
npm install express

Create server.js:

const express = require("express");
const path = require("path");

const app = express();

// 1. Hashed static assets: cache for a year.
//    Build tools put content hashes in these filenames,
//    so a URL's content never changes.
app.use(
  "/assets",
  express.static(path.join(__dirname, "dist/assets"), {
    immutable: true,
    maxAge: "365d",
    etag: true,
  })
);

// 2. HTML: short shared-cache TTL with stale-while-revalidate.
app.get("/", (req, res) => {
  res.set(
    "Cache-Control",
    "public, max-age=0, s-maxage=60, stale-while-revalidate=300"
  );
  res.type("html").send("<h1>Cached at the edge for 60 seconds</h1>");
});

// 3. Public API data: brief shared caching to absorb bursts.
app.get("/api/products", (req, res) => {
  res.set("Cache-Control", "public, s-maxage=30, stale-while-revalidate=60");
  res.json([
    { id: 1, name: "Widget" },
    { id: 2, name: "Gadget" },
  ]);
});

// 4. Personalized data: never cached anywhere shared.
app.get("/api/me", (req, res) => {
  res.set("Cache-Control", "private, no-store");
  res.json({ user: "current-user", plan: "pro" });
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

Run it locally with node server.js and inspect the headers:

curl -sI http://localhost:3000/ | grep -i cache-control
curl -sI http://localhost:3000/api/me | grep -i cache-control

Deploy it to Railway from the project directory:

railway init
railway up

Railway detects the Node app and builds it with Railpack. Generate a public domain from the service's settings, or with railway domain.

Enable Railway's built-in CDN

Railway includes a CDN that caches responses at edge locations worldwide. It is available on all plans at no extra cost, off by default, and enabled per service. Once it's on, cache hits are served entirely from the edge: they use none of your service's compute and incur no network egress charges.

Enable it in the service's Settings under the Edge section, or from the CLI:

railway cdn enable --service web

It applies to all of the service's domains, both the Railway-provided *.up.railway.app domain and custom domains, with no DNS changes.

How the CDN reads your headers

The headers you set above map directly onto the CDN's behavior:

  • Static assets (identified by response Content-Type, such as image/*, text/css, application/javascript) are cached even without headers, using your max-age when present and a configurable default TTL otherwise. Your one-year immutable header means they are fetched from your service once.
  • HTML is cached in the default Auto mode only when you send a max-age or s-maxage, which the example does. The CDN resolves the TTL from s-maxage first, then max-age.
  • stale-while-revalidate is honored by default: within the window, the edge answers instantly from cache and refreshes in the background, coalescing a burst of requests into a single origin fetch.
  • private and no-store responses are never cached, and neither is any response carrying Set-Cookie.
  • JSON and other non-HTML, non-static responses are cached only with an explicit max-age or s-maxage, so the /api/products policy works and unheadered API routes stay dynamic.

Verify it is working

Request the same URL twice and read the x-cache response header:

curl -sI https://your-app.up.railway.app/ | grep -i x-cache
curl -sI https://your-app.up.railway.app/ | grep -i x-cache

The first request returns x-cache: MISS, the second x-cache: HIT. A third result, DYNAMIC, means the response was not cacheable, usually a missing freshness directive on HTML. You can also hit /.railway/cdn-trace on any CDN-enabled domain to see which edge location is serving you.

Handle deploys

By default, Railway purges cached HTML on each successful deploy, so a new page version cannot reference old asset URLs while your hashed assets stay cached. You can also purge manually:

railway cdn purge html
railway cdn purge all

Using an external CDN instead

The same headers work with any CDN. Point the CDN's origin at your Railway service's domain and it will respect s-maxage, stale-while-revalidate, and private the same way. This is the right choice when you need per-URL invalidation, custom edge logic, or a CDN your organization already standardizes on. For a full walkthrough with a managed distribution, see Add a CDN using Amazon CloudFront.

If you use an external CDN, leave Railway's CDN off for that service so you are not debugging two cache layers.

Move the origin closer too

Caching hides origin latency for repeat requests, but cache misses and dynamic routes still travel to your service, so pick a deployment region near your largest user base. Railway's edge network routes each request to the nearest entry point regardless of where the service runs.

Next steps