Server-Side Caching

CDN vs Application Cache — Where to Cache What

By Domain India Team · DomainIndia EngineeringPublished · Updated 6 min read
Knowledge base article
Contents (19 sections)
TL;DR

Your app has caching opportunities at 5 distinct layers — browser, CDN edge, reverse proxy, app memory, database. Putting the right cache at the right layer cuts response time from 500ms to 20ms. This guide maps every cache decision with DomainIndia + Cloudflare examples.

The 5 caching layers

LayerLatency to userTTL typicalWho sets it
Browser (HTTP cache)0msminutes-daysHTTP headers
CDN edge (Cloudflare)10-50msseconds-daysHTTP headers + rules
Reverse proxy (nginx, Varnish)1-5msseconds-minutesproxy config
Application cache (Redis, memcached)1-2msminutes-hoursapp code
Database (query cache, materialised views)0-10msvariesDB config / app

Each layer catches different things. Use them together.

Decision matrix — what goes where

Content typeBest cache layerTTL
Static assets (CSS, JS, images, fonts)Browser + CDN1 year
Public HTML pages (marketing)CDN1-24 hours
Personalised HTML (dashboard)App cache (per-user)Minutes
DB query results (user profile)App (Redis)Minutes
DB query results (product list)App (Redis) + CDN API cache1-5 min
Third-party API responsesApp (Redis)Hours
Computed views (aggregations)Materialised view + AppHours-days
Session dataApp (Redis)Until expiry
Rate-limit countersApp (Redis)Window

Layer 1 — Browser cache

The cheapest cache. Served without network request at all.

Set HTTP headers:

nginx
location ~* .(jpg|jpeg|png|gif|webp|svg|css|js|woff2|ttf)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header Vary "Accept-Encoding";
}

location /api/ {
    expires -1;   # no cache for API
    add_header Cache-Control "no-store";
}

location / {
    expires 1h;
    add_header Cache-Control "public, must-revalidate";
}

Fingerprinting your static files breaks cache automatically:

bundle.abc123.js — when content changes, hash changes, browser refetches. Set TTL = forever.

Layer 2 — CDN edge (Cloudflare)

Cloudflare caches what you tell it to. By default: static files yes, HTML no.

Cache everything with Page Rule

Cloudflare Dashboard → Caching → Page Rules → Add:

code
URL: *yourcompany.com/*
Settings:
  Cache Level: Cache Everything
  Edge Cache TTL: 2 hours
  Browser Cache TTL: 30 minutes

Now static HTML pages cache at edge too.

Bypass cache for dynamic paths

code
URL: *yourcompany.com/admin/*
Settings:
  Cache Level: Bypass

And for cookies:

code
URL: *yourcompany.com/*
Settings:
  Cache Level: Cache Everything
  Edge Cache TTL: 2h
  Cache by Device Type: On    (separate mobile/desktop variants)
  Bypass Cache on Cookie: (wp_logged_in|sessionid|auth_token)

When auth_token cookie is set → Cloudflare skips cache, hits origin. Logged-out users see cached; logged-in users see fresh.

Cloudflare's own analytics

See hit ratio: Cloudflare → Analytics → Cache.

Target: >70% hit ratio for content sites. Dashboard apps: may be lower but still benefits from static-asset caching.

Purge cache after deploy

bash
curl -X POST "https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/purge_cache" 
    -H "Authorization: Bearer YOUR_API_TOKEN" 
    -H "Content-Type: application/json" 
    --data '{"purge_everything":true}'

Or purge specific URLs:

bash
--data '{"files":["https://yourcompany.com/api/users","https://yourcompany.com/"]}'

Add to your deploy pipeline.

Layer 3 — Reverse proxy (nginx / Varnish)

On your VPS — caches after origin generates, before sending to Cloudflare.

nginx:

nginx
proxy_cache_path /var/cache/nginx keys_zone=STATIC:10m max_size=1g inactive=60m;

location /api/ {
    proxy_cache STATIC;
    proxy_cache_valid 200 5m;
    proxy_cache_valid 404 1m;
    proxy_cache_key "$scheme$request_method$host$request_uri";
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    add_header X-Cache-Status $upstream_cache_status;

    proxy_pass http://app;
}

X-Cache-Status: HIT / MISS header shows cache state.

Varnish (alternative, more powerful):

vcl
sub vcl_backend_response {
    set beresp.ttl = 5m;
    set beresp.grace = 1h;    # serve stale during outages
}

See our Server-Side Caching article.

Layer 4 — Application cache (Redis)

The most flexible layer — your code decides what to cache.

See full patterns in our Redis Beyond Caching article.

Classic cache-aside:

typescript
async function getUser(id: string) {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);
  const user = await db.user.findUnique({ where: { id } });
  await redis.setex(`user:${id}`, 300, JSON.stringify(user));
  return user;
}

Cache tags (Laravel, Rails) — invalidate related keys together:

php
// Laravel
Cache::tags(['users', "user:{$id}"])->put("user.{$id}.profile", $data, 300);
// Later invalidate just this user:
Cache::tags(["user:{$id}"])->flush();

Layer 5 — Database-level

  • Postgres materialized views for expensive aggregations:
    sql
    CREATE MATERIALIZED VIEW daily_stats AS
      SELECT date(created_at), count(*), sum(amount)
      FROM orders GROUP BY 1;
    REFRESH MATERIALIZED VIEW daily_stats;   -- schedule hourly
  • Postgres pg_prewarm — load hot tables into shared_buffers on startup
  • MySQL query cache (removed in 8.0; use Redis instead)
  • Read replicas — not caching exactly, but distributes load

Invalidation strategies

Three approaches:

TTL-only (easy, eventual consistency)

code
Cache: 300s TTL
Updates: ignored
Result: users see stale up to 5 min

Works for: product catalogs, articles, rankings.

Explicit invalidation

typescript
async function updateUser(id, data) {
  await db.user.update({ where: { id }, data });
  await redis.del(`user:${id}`);
}

Works for: user profiles, settings, anything with clear "owner".

Write-through

typescript
async function updateUser(id, data) {
  const user = await db.user.update({ where: { id }, data });
  await redis.setex(`user:${id}`, 300, JSON.stringify(user));
}

Cache always matches DB. Best consistency, most code.

Cache stampede prevention

When cache expires, 1000 concurrent requests all hit DB. Prevent with:

Lock (SETNX):

typescript
async function withLock(key, callback, lockTtl = 10) {
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, '1', { NX: true, EX: lockTtl });
  if (acquired) {
    try { return await callback(); } finally { await redis.del(lockKey); }
  }
  // Someone else is computing — wait briefly and try cache again
  await new Promise(r => setTimeout(r, 100));
  return JSON.parse(await redis.get(key));
}

Probabilistic early refresh: (see Laravel Cache article)

Hit ratio targets

Measure. Without data, you're guessing:

LayerTarget hit rate
Browser cache60-80%
Cloudflare>70% (content), >50% (apps)
nginx proxy cache>80% for what you cache
Redis app cache>80%
DB query cache>90% for cached queries

Monitor via:

  • redis-cli INFO statskeyspace_hits / (keyspace_hits + keyspace_misses)
  • Cloudflare Analytics → Cache
  • X-Cache-Status nginx header aggregated in logs

Common pitfalls

Caching user-specific data publicly
user A sees user B's dashboard. Include user ID in cache key OR bypass for authed.
TTL too long
stale content drives users away. Balance: 5 min is often right.
No invalidation on write
changes don't appear until TTL expires. Feels broken.
Caching API responses with personalised fields
some fields are user-specific. Split: public fields cached, user fields not.
"Cache everything" — then inconsistent reads
user posts comment, refresh, comment isn't there. Invalidate on write.
Serialising huge objects in cache
Redis values should be <1 MB ideally. Cache summaries, fetch detail.

FAQ

Cloudflare + nginx cache — overkill?

Depends on traffic. Cloudflare handles 80% globally; nginx catches what Cloudflare misses (geographic origin requests). For most sites: Cloudflare alone is fine.

How do I cache logged-in users?

Either per-user keys (expensive in Redis memory) or differentiate static vs dynamic parts. See our Next.js App Router article on Partial Prerendering.

Does Cloudflare cost extra for high cache usage?

Free plan includes unlimited Cloudflare bandwidth. Origin bandwidth is what's reduced — huge savings.

CDN for APIs?

Yes for public read-only APIs. Set short TTL (30-60s) and Cache-Control: public. User-specific APIs: bypass.

When does caching hurt?

When it hides bugs (stale data looks like app is broken). When cost of inconsistency > cost of slower response. When debug cycles lengthen ("is it cache or code?").

Combine DomainIndia hosting + Cloudflare for a fast cache stack. View hosting

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app