Caching
Answer repeated reads from Redis, cleared automatically on every write.
Turn it on#
Cache list and read responses in Redis, so repeated requests skip the database. Worth it for read-heavy data that changes through these routes — catalogues, settings, reference data. Three steps:
Install a Redis client
npm install ioredis # or: npm install redisSet the URL in .env
REDIS_URL=redis://localhost:6379Add cache to a router
app.use('/api/products', createRouter({ model: Product, cache: true, // or { ttl: 300 } allowedFields: ['name', 'price'], }));
That is all. The first request reads the database and stores the response; the next identical request is answered from Redis.
What gets cached#
| Route | Cached? |
|---|---|
GET / | Yes — every filter, search, sort and page combination separately |
QUERY / | Yes — keyed by the JSON body |
GET /:id | Yes |
| POST · PATCH · DELETE | Never — a successful write clears the model's cache |
- Always fresh after a write. A successful create, update or delete clears every cached response for that model — on every router using it — before the response is sent. A client that reads right after its own write sees the change.
- Only successes are cached. Errors (400, 404…) are always recomputed.
- Entries expire after
ttlseconds (60 by default), which also bounds staleness from changes made outside these routes.
Every response carries X-Cache: HIT or X-Cache: MISS:
curl -i localhost:3000/api/products | grep -i x-cache # X-Cache: MISS
curl -i localhost:3000/api/products | grep -i x-cache # X-Cache: HIT
Options#
createRouter({
model: Product,
allowedFields: ['name', 'price'],
cache: {
ttl: 300, // seconds · env CACHE_TTL · default 60
url: process.env.REDIS_URL, // env REDIS_URL
prefix: 'shop:', // env CACHE_PREFIX · default 'cs:'
vary: (req) => req.auth?.userId, // default: one cache per signed-in user
timeoutMs: 150, // give up on Redis after this, use the database
// client: myRedisClient, // an ioredis / node-redis client you already have
// store: createMemoryCacheStore(), // no Redis: in-process cache for development
},
});
| Option | Default | Why use it |
|---|---|---|
ttl | 60 · CACHE_TTL | Longer for data that rarely changes (a catalogue), shorter for busy data. |
url | REDIS_URL | Point at a different Redis than the default one. |
client | — | Reuse the Redis connection your app already has. |
store | — | Any object with get and set — the memory store, or your own backend. |
prefix | 'cs:' · CACHE_PREFIX | Keep several apps apart in one Redis. |
vary | the signed-in user | What else a response depends on. See below. |
timeoutMs | 150 | How long a request waits for Redis before falling back to the database. |
namespace | the mount path | Rarely needed: override the router part of the cache key. |
Summary table: createRouter → Cache.
Per-user data#
By default each signed-in user gets their own cache entries (req.auth.userId is part of the key), so one user is never sent another user's page. Public routers without auth share one cache.
// Everyone behind the guard sees the same data: share one cache.
createRouter({ model: Product, middlewares: [auth.requireAuth], cache: { vary: () => '' }, allowedFields: [] });
// Responses differ by something else — say, the tenant header:
createRouter({ model: Invoice, cache: { vary: (req) => req.headers['x-tenant-id'] }, allowedFields: [] });
Keys are built from the request after your middleware runs, so a middleware that pins a filter (like only my records) is part of the key too.
Changes made elsewhere#
Writes through these routes clear the cache automatically. If your own code changes records directly, clear it yourself — or let ttl expire it:
const products = createRouter({ model: Product, cache: true, allowedFields: ['name'] });
app.use('/api/products', products);
// Somewhere else in your app
await Product.updateMany({ category: 'sale' }, { $set: { price: 10 } });
await products.invalidateCache();
With ControllerSets, it is controller.invalidateCache().
When Redis is down#
The cache speeds things up; it never breaks your API.
- Every cache call has a
timeoutMslimit (150 ms). If Redis is slow, down or unreachable, the request is served from the database as if caching were off. - Failures are logged at most once every 30 seconds, not on every request.
- When Redis comes back, caching resumes on its own — no restart.
- Set
CACHE_ENABLED=falseto switch caching off everywhere without a deploy.
Without Redis (development)#
No Redis on your machine? Use the in-process store — same behaviour, nothing to install. Each process has its own copy, so use Redis in production.
import { createRouter, createMemoryCacheStore } from 'express-controller-sets';
const cacheStore = process.env.REDIS_URL ? undefined : createMemoryCacheStore();
createRouter({
model: Product,
cache: cacheStore ? { store: cacheStore } : true,
allowedFields: ['name', 'price'],
});
Environment#
REDIS_URL=redis://localhost:6379 # rediss:// for TLS · redis://:password@host:6379/0
CACHE_TTL=60 # seconds
CACHE_PREFIX=cs:
CACHE_ENABLED=true # false switches caching off everywhere
Provider examples: .env.example → Redis cache.