An opt-in, in-memory response cache for read-heavy, slow-changing endpoints (e.g. a public storefront or any GET whose result rarely changes). It cuts database load and latency without a separate cache service: enable it per route (or per file), and the framework serves cache hits after authentication/role checks and before the handler runs.
invalidates) or imperative (invalidateCache()), and it does not
travel between instances (section 6).Available since 3.5.0.
Add a cache prop to a route in routes.ts. It accepts three authored forms:
cache: true // enable with the global default TTL
cache: 3600 // enable with a TTL of 3600 seconds
cache: { // full form
enabled?: boolean // default: true when the object is present
ttl?: number // seconds; falls back to the global default (options.cache.ttl)
keyGroup?: string // logical group for invalidation; default = the api folder (area)
invalidates?: string | string[] // key-group(s) to flush after a successful (2xx) response
}
Example — a cached public list plus the admin write that invalidates it:
// src/api/public/routes.ts
{ method: 'GET', path: '/vehicles', roles: [roles.public], handler: 'public.vehicles',
cache: { ttl: 3600 } }
// src/api/vehicles/routes.ts (admin)
{ method: 'PUT', path: '/:id', roles: [roles.admin], handler: 'vehicle.update',
cache: { invalidates: ['public'] } }
cache can also be set once at the file-level config, and every route in that file inherits it (a
per-route cache always overrides the file-level one). This is the recommended way to adopt it across a
resource:
// src/api/public/routes.ts — cache every GET in this file under the 'public' key-group
export default {
config: { title: 'Public', controller: 'controller', cache: { ttl: 3600 } },
routes: [ /* GET routes — all cached; a POST here is a mutation and is never cached */ ]
}
// src/api/vehicles/routes.ts — invalidation-only: don't cache admin (per-user) responses,
// just flush the public storefront on every successful mutation.
export default {
config: { title: 'Vehicles', controller: 'controller', cache: { enabled: false, invalidates: ['public'] } },
routes: [ /* GET stay uncached; POST/PUT/PATCH/DELETE flush 'public' */ ]
}
The read hook (preHandler, after auth) returns a stored response on a hit; the write hook (onSend) stores a
fresh one. A response is cached only when all of these hold:
cache.enabled) and the global cache is enabled;Set-Cookie on the response;On a hit the stored status, body and the content-type + v-* (pagination) headers are replayed, so
paginated responses keep their metadata. Non-2xx responses (validation errors, 404, 5xx) are never cached,
so a “not found” never sticks.
The key is:
keyGroup :: container :: subject | roles :: METHOD url
control for the platform, tenant:<id> inside a customer’s container.req.user?.externalId (or the API-token id), or anon for unauthenticated callers.Why the container is written out. It could be inferred from the rest of the key in most shapes, and that is exactly the reason it is stated. Defect D-15 was a cache key that happened to be unique: the v4 data-layer cache keyed on the SQL, and with a schema-per-tenant strategy the SQL of two tenants is the same string, so the key stopped isolating the day the schema moved out of it. Isolation that holds by accident holds until something else changes.
Consequences:
public callers share one entry (the storefront is the same for everyone).url (path + query string) is part of the key, so different filters/pages are cached separately.The store is a single in-memory instance (a Map, no external dependency):
ttl (or the global default). Expiry is enforced lazily on read
and by a periodic background sweep.maxEntries, the
least-recently-used entry is evicted.Global defaults live in general.ts under options.cache:
// src/config/general.ts
export default {
name: 'general',
options: {
cache: {
enabled: true, // master switch for the whole feature
ttl: 3600, // default TTL (seconds) for routes without an explicit ttl
maxEntries: 1000 // LRU cap before eviction
}
}
}
When ttl is not declared, the framework picks it from the shape of the deployment it can
see:
| Deployment | Default ttl |
Why |
|---|---|---|
no tenants block |
3600s | a single application, usually a single process |
tenants declared |
60s | the deployment that gets replicated, and the store is per process |
The reason is section 6: an invalidation reaches the instance that served the request and no other, so behind several instances the TTL is what bounds how long the rest stay behind. One hour of that is not a cache, it is a stale read with a timer.
The residual case is worth stating rather than hiding: a single-tenant application can also
run behind several instances, and there the one-hour default is the unsafe one. Declare
cache.ttl explicitly when that is the shape you run.
options.cache.enabled = false (or a route ttl/global ttl of 0) turns caching off: every request runs
fresh. With the master switch off the store stays empty, so declared invalidates become no-ops (the onSend
hook short-circuits, no store scan). The effective configuration is logged at startup, and it says where the
number came from:
Cache 🧊 enabled: ttl 60s (default with tenants), maxEntries 1000, strategy LRU+TTL
Cache 🧊 is per process: an invalidation reaches this instance only. TTL bounds the staleness.
Invalidation is by key-group (the default key-group is the api folder, e.g. src/api/public/* → public).
Add invalidates to a mutating route (or its file-level config.cache): on a successful (2xx) response the
listed key-groups are flushed.
cache: { invalidates: ['public'] } // one group
cache: { invalidates: ['public', 'brands'] } // several
cache: { enabled: false, invalidates: ['public'] } // don't cache this route, only invalidate
Import the helper (also mirrored on global.cache) and call it from anywhere — a service, a hook, a job:
import { invalidateCache, cache } from '@volcanicminds/backend'
invalidateCache('public') // flush one key-group
invalidateCache(['public', 'brands']) // flush several
invalidateCache() // flush everything
// global.cache is the same facade:
global.cache.invalidate('public')
global.cache.flushAll()
global.cache.del(key) // delete a single computed key
global.cache.stats() // { size, hits, misses, enabled, ttl, maxEntries }
This is a known limit and a decision, not an oversight (defect D-26). The store is a Map
in the process that serves the request. A declarative invalidates, or a call to
invalidateCache(), empties that instance’s entries and reaches no other instance, so behind a
load balancer a stale entry survives on the other replicas until it expires.
What follows from it:
maxEntries. A route with many distinct query-string combinations creates many
keys — keep the client’s allowed filters small, or lower maxEntries.cache on routes whose response is fully determined by
method + url + tenant + subject + roles. If a handler varies its output by something else (a custom header, the
time of day, …), don’t cache it.// src/api/public/routes.ts → cache all storefront GETs for 1h (key-group 'public')
config: { /* … */ cache: { ttl: 3600 } }
// src/api/vehicles/routes.ts → admin writes flush the storefront
// src/api/brands/routes.ts
// src/api/company/routes.ts
config: { /* … */ cache: { enabled: false, invalidates: ['public'] } }
Result: the public site is served from memory and refreshed only when the admin actually changes something (or after the TTL as a safety net).