Skip to content

Caching ​

Not every request needs to hit the database. Cache is "remember the answer for a bit" - in HTTP headers, in process memory, or in Redis. Same idea as React Query, just on the other side of the wall.

Mental model (FE → Express) ​

Front-endServer
React Query / SWR / stale-while-revalidateresponse cache / Redis
staleTimeTTL (max-age, EXPIRE)
invalidateQueries on mutationdelete cache key on POST/PATCH/DELETE
CDN / browser disk cacheCache-Control, ETag

Caching isn't one knob. You've got layers:

  1. Browser / CDN - HTTP headers on the response
  2. App memory - Map / lru-cache inside the Node process
  3. Shared store - Redis (survives restarts, works with multiple servers)

Start with (1) + (2). Reach for Redis when one process isn't enough.

HTTP cache headers (cheap wins) ​

For public, rarely changing JSON (config, CMS-ish lists):

ts
app.get('/api/public/config', (_req, res) => {
  res.set('Cache-Control', 'public, max-age=60')
  res.json({ featureFlags: { darkMode: true } })
})

For private user data you usually want:

ts
res.set('Cache-Control', 'private, no-store')

Comments for the code above:

  • public - CDN/browser may share the response
  • private - only the end user's browser
  • no-store - don't keep a copy (auth responses, anything sensitive)
  • max-age=60 - fresh for 60 seconds

ETag for conditional requests (skip the body when unchanged):

ts
import { createHash } from 'node:crypto'

app.get('/api/public/changelog', (req, res) => {
  const body = JSON.stringify({ entries: getChangelog() })
  const etag = `"${createHash('sha1').update(body).digest('hex')}"`

  if (req.get('If-None-Match') === etag) {
    return res.status(304).end()
  }

  res.set('ETag', etag)
  res.set('Cache-Control', 'public, max-age=30')
  res.type('json').send(body)
})

FE map: the browser (or your fetch cache) sends If-None-Match - same energy as "only refetch if stale".

In-memory cache in Express ​

Tiny helper - good for hot reads on a single instance:

ts
// src/cache.ts
type Entry<T> = { value: T; expiresAt: number }

const store = new Map<string, Entry<unknown>>()

export function cacheGet<T>(key: string): T | undefined {
  const hit = store.get(key)
  if (!hit) return undefined
  if (Date.now() > hit.expiresAt) {
    store.delete(key)
    return undefined
  }
  return hit.value as T
}

export function cacheSet<T>(key: string, value: T, ttlMs: number) {
  store.set(key, { value, expiresAt: Date.now() + ttlMs })
}

export function cacheDel(key: string) {
  store.delete(key)
}

export function cacheDelPrefix(prefix: string) {
  for (const key of store.keys()) {
    if (key.startsWith(prefix)) store.delete(key)
  }
}

Use it around a expensive read:

ts
notesRouter.get('/', (req: AuthedRequest, res) => {
  const key = `notes:${req.user!.id}`
  const cached = cacheGet<Note[]>(key)
  if (cached) {
    res.set('X-Cache', 'HIT')
    return res.json({ notes: cached })
  }

  const notes = listNotesForUser(req.user!.id)
  cacheSet(key, notes, 30_000) // 30s
  res.set('X-Cache', 'MISS')
  res.json({ notes })
})

Invalidate on write - otherwise your SPA sees ghosts:

ts
notesRouter.post('/', (req: AuthedRequest, res, next) => {
  try {
    const note = createNote(req.user!.id, …)
    cacheDel(`notes:${req.user!.id}`)
    broadcast({ type: 'note:created', note })
    res.status(201).json({ note })
  } catch (err) {
    next(err)
  }
})

As you can see, the hard part isn't Map - it's remembering to invalidate.

For production in-process caching, lru-cache caps memory so a huge keyspace can't eat the heap.

Redis (when memory isn't enough) ​

bash
npm install ioredis
ts
import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL)

export async function redisGet(key: string) {
  const raw = await redis.get(key)
  return raw ? JSON.parse(raw) : undefined
}

export async function redisSet(key: string, value: unknown, ttlSeconds: number) {
  await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds)
}

export async function redisDel(key: string) {
  await redis.del(key)
}

Same get/set/del story - shared across Node processes, survives restarts. Also the usual store for session backends and WebSocket pub/sub (What next).

You don't need Redis for the tutorial companion. Wire it when you deploy more than one instance.

Middleware pattern (optional) ​

ts
function cacheMiddleware(ttlMs: number, keyFn: (req: Request) => string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const key = keyFn(req)
    const hit = cacheGet<unknown>(key)
    if (hit) {
      res.set('X-Cache', 'HIT')
      return res.json(hit)
    }

    const originalJson = res.json.bind(res)
    res.json = (body: unknown) => {
      cacheSet(key, body, ttlMs)
      res.set('X-Cache', 'MISS')
      return originalJson(body)
    }
    next()
  }
}

// careful: only for idempotent GETs with a stable key
app.get(
  '/api/public/stats',
  cacheMiddleware(10_000, () => 'public:stats'),
  getStatsHandler,
)

Monkey-patching res.json is a bit spicy - fine for learning, easy to abuse. Prefer explicit get/set in handlers when things get subtle (auth, varying query strings).

What not to cache (or be careful) ​

  • Responses that include other users' data under a shared key
  • Anything with Authorization unless the key includes user id (and still prefer short TTL)
  • POST/PATCH/DELETE success bodies as if they were GETs
  • Unlimited in-memory maps with user-controlled keys (memory DoS)

FE + server cache together ​

Your SPA can cache with React Query and the API can cache with Redis. That's OK - different layers.

Typical flow:

  1. SPA has fresh data → no network
  2. SPA stale → fetch → API memory/Redis HIT → fast JSON
  3. Both miss → DB → fill caches → respond

Invalidate both sides on mutations (client invalidateQueries, server cacheDel).

Companion ​

examples/notes-api caches GET /api/notes per user for 30s (X-Cache: HIT|MISS) and clears that key on create/delete. No Redis required.

Soft checklist ​

  • [ ] Decide layer: headers / memory / Redis
  • [ ] Key includes everything that changes the answer (user id, query filters)
  • [ ] TTL is intentional, not "forever"
  • [ ] Writes invalidate the right keys
  • [ ] Private data isn't Cache-Control: public

Next: wrapping up the runway.

Best,

-- ł.

Notes-style Express tutorial for front-end folks