controller-sets v3.3.0

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:

  1. Install a Redis client

    npm install ioredis        # or: npm install redis
  2. Set the URL in .env

    REDIS_URL=redis://localhost:6379
  3. Add 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#

RouteCached?
GET /Yes — every filter, search, sort and page combination separately
QUERY /Yes — keyed by the JSON body
GET /:idYes
POST · PATCH · DELETENever — 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 ttl seconds (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
  },
});
OptionDefaultWhy use it
ttl60 · CACHE_TTLLonger for data that rarely changes (a catalogue), shorter for busy data.
urlREDIS_URLPoint 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_PREFIXKeep several apps apart in one Redis.
varythe signed-in userWhat else a response depends on. See below.
timeoutMs150How long a request waits for Redis before falling back to the database.
namespacethe mount pathRarely needed: override the router part of the cache key.

Summary table: createRouter → Cache.

One connection for many routers#

Routers given the same url already share one connection. To configure it once, or reuse a client your app already has, create a store and pass it to each router:

import { createRouter, createRedisCacheStore } from 'express-controller-sets';

const store = createRedisCacheStore({ url: process.env.REDIS_URL });   // or { client: myRedis }

app.use('/api/products', createRouter({ model: Product, cache: { store, ttl: 300 }, allowedFields: ['name'] }));
app.use('/api/categories', createRouter({ model: Category, cache: { store }, allowedFields: ['name'] }));

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 timeoutMs limit (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=false to 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.

Esc
↑ ↓ to move↵ to open