Appearance
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-end | Server |
|---|---|
| React Query / SWR / stale-while-revalidate | response cache / Redis |
staleTime | TTL (max-age, EXPIRE) |
| invalidateQueries on mutation | delete cache key on POST/PATCH/DELETE |
| CDN / browser disk cache | Cache-Control, ETag |
Caching isn't one knob. You've got layers:
- Browser / CDN - HTTP headers on the response
- App memory -
Map/lru-cacheinside the Node process - 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 responseprivate- only the end user's browserno-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 ioredists
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
Authorizationunless 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:
- SPA has fresh data → no network
- SPA stale →
fetch→ API memory/Redis HIT → fast JSON - 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,
-- ł.