Response caching
VextJS provides declarative route-level response caching through the cache route option. Route middleware and the authentication Guard run first; on a cache hit, parameter validation and the handler are skipped and the cached result is returned. This suits public queries that tolerate brief staleness, not endpoints whose handlers must execute side effects every time.
This page focuses on JSON APIs. res.render() has separate render-cache integration; see Render Data and Cache. Do not apply these settings uncritically to arbitrary HTML, streams, or personal data.
Complete two-file example and verification
In the API project from Quick Start, merge the following configuration and add the route. The counter only shows when the handler executes; it lives in one process and resets on restart, so it is not persistent business data.
After npm run dev, send these requests in order from another terminal. On Windows PowerShell, use curl.exe:
The first two requests return 200 with data.executions: 1 and MISS/HIT headers respectively. Invalidation returns 200 with data.invalidated: true; the next GET is a MISS with count 2; the refresh request bypasses cache and has count 3. Use the same process and finish within 60 seconds to compare this sequence.
Try different Accept-Language values to verify separate entries; swapping the order of identical query parameters should hit the same entry. Cookie requests bypass this example through its condition, while Authorization requests bypass by default request policy, so they do not demonstrate public cache hits. The invalidation endpoint is for local demonstration only; protect it in a business deployment.
After development verification, stop dev, run npm run build -- --typecheck and npm start, and repeat the requests. The production process starts its counter from zero. Do not run two services on port 3000 at the same time.
Basic usage
The db and handler references below stand for application code. These snippets explain options and cannot start on their own. Use the complete example above for first verification.
Numeric abbreviation
The simplest configuration, specifying the cache validity period in milliseconds:
Complete configuration
Explicitly disable
Configuration options
RouteOptions.cache
Global configuration (config.cache)
config.cache controls the response caching runtime for the entire application. Whether a route is cached is still determined by each route's RouteOptions.cache.
The response cache runtime is handled by response-cache-kit, and the underlying cache is managed by cache-hub. Vext does not open custom Store for response cache; if you need to adjust the underlying runtime, please configure cache.cacheHub. Session Store is separate: use createCacheSessionStore(cacheLike) with its own prefix instead of reusing app.cache or config.cache.cacheHub.
config.cache field
Memory cacheHub
ttl is required in the public type for cache: { ttl: 60_000 }. At runtime a missing or zero object TTL may be filled by the global fallback, so cache: { ttl: 0 } does not reliably disable caching. Use cache: false or numeric cache: 0. In Memory mode, matching fields inside cacheHub override the outer convenience settings.
Redis cacheHub
Redis mode is suitable for multiple instances to share response cache. When enabling Redis/MultiLevel in a business project, ioredis needs to be installed:
Response caching chooses its Redis target by client → url → redis://localhost:6379; it does not automatically read VEXT_REDIS_URL or REDIS_URL. A supplied client is owned by its caller; the framework closes a connection it creates from a URL at app shutdown.
The current Vext response-cache namespace is fixed as vext-route-cache; there is no public config.cache.namespace. Separate applications or environments should use separate Redis databases or instances. Changing metaKeyPrefix, broadcast channel, or one route key does not isolate every response entry or the scope of clear().
MultiLevel cacheHub
MultiLevel uses the memory of this process as L1 and Redis as L2. It is suitable for services that want to reduce the reading pressure of Redis but still need to share the cache across processes.
lease and distributed
lease is used to reduce multi-process cache breakdown: after the same key expires, one process obtains the lease and executes the handler, and other processes wait briefly for the cache to be written. By default, the system continues to return to the source after waiting timeout, with priority given to ensuring availability.
The following fragments are fields inside cache.cacheHub in Redis or MultiLevel mode. Lease uses the cache's Redis layer; it does not invalidate other processes' L1 entries.
distributed is used to broadcast invalidation actions such as app.cache.invalidate(tag) and app.cache.clear() to other instances:
The distributed connection chooses redis or redisUrl independently; it does not inherit outer cacheHub.client/url or cacheHub.redis.url, and defaults to localhost:6379 when neither is supplied. Check the broadcast address when using remote Redis. If you set instanceId manually, every instance needs a different value or it may ignore another instance's message as its own.
invalidate(tag) and clear() invalidate the local instance before publishing. Return does not mean every subscriber has processed the message. app.cache.delete(key) is not broadcast, so another instance's MultiLevel L1 may retain the old value until expiry. Use tags and correctly configured distributed invalidation when coordinating a group; broadcast is not a strongly consistent transaction.
Caching behavior
By default, only GET/HEAD requests are processed, and successful 2xx responses sent with res.json() are captured, excluding 204. res.render() has a dedicated cache path in the Frontend guide. Ordinary res.text(), streams, downloads, and redirects do not write through the JSON cache path.
Response header
These headers apply only to responses entering the relevant cache flow. cacheControl: false disables the generated public header, not server caching, and does not remove a header set by application code. Responses with private or no-store are not written to server storage.
partitionKey isolates only server cache, not browser, proxy, or CDN caches. Set a suitable HTTP cache policy for personalized responses; a partition does not make the default public appropriate. private/no-store prevent storage but are not enough by themselves to prevent concurrent reuse; see Concurrent origin fetches.
Cache Key algorithm
The actual storage path currently uses the underlying createVextLegacyKey: method plus normalized URL, then partition and vary request headers. The versioned JSON tuple from defaultCacheKey() is currently used for flow key/Hook records, not as the stored key to delete. Prefer tag invalidation over depending on an internal key format.
- Query parameters are automatically sorted (
?b=2&a=1≡?a=1&b=2) - Requests with
Authorizationare not cached by default unlesspartitionKeyis configured orallowAuthorizationCache: trueis explicitly set - An origin result for a Cookie-bearing request is not written by default; reading an existing entry has the limitation below
- When you need to differentiate cache by user or tenant, use
partitionKeyfirst - When using a custom
key,partitionKeyandvarywill still be appended to the underlying key
In the current implementation, allowCookieCache: false prevents writing an origin response but does not prevent a Cookie-bearing request from reading an existing public entry. Test “anonymous request populates cache → Cookie-bearing request”; a cold-cache test alone misses this behavior. To bypass all Cookie requests, explicitly set condition: (req) => req.headers.cookie === undefined, or disable caching for the route. The complete example above includes this condition.
Scenarios without caching
The following list includes both early bypass and responses that are not written. “Not written” does not prove concurrent requests cannot share a result; see Concurrent origin fetches.
204 No Contentresponse- Non-2xx status codes (3xx/4xx/5xx)
- Response contains
Set-Cookie - Response header contains
Cache-Control: no-storeorprivate - The request header contains
Cache-Control: no-storeorno-cache - With
Authorizationand nopartitionKey/allowAuthorizationCacheconfigured - An origin result with Cookie and no
allowCookieCache(does not guarantee bypass of an existing entry) - A response outside JSON or dedicated render-cache capture
- A Session with pending changes that prevents storage of the current response
cache: falseexplicitly disabledcache: 0or negative valueconditionreturnsfalse- Custom
keyreturns empty string
Runtime API
Operate the cache in the route handler through app.cache:
app.cache.clear() clears the current Vext response-cache namespace. In Redis/MultiLevel mode it does not clear the whole Redis database, although Vext applications sharing the same database may affect each other. Memory cache and statistics cover only the current runtime instance; cluster worker memory statistics are not automatically aggregated. The numeric statistics above are illustrative.
delete() needs the exact final key: do not copy the no-query example for keys with query, vary, partition, or custom key. Prefer tag invalidation for a group of variants. Complete the business write before invalidating the corresponding tag; these steps are not automatically one database transaction, so the business must handle failure and concurrent refill.
Redis adapter stats() currently returns all-zero placeholders, which do not prove Redis has no entries. MultiLevel statistics cover this process's L1, not L1+L2 or the cluster. Distinguish existing-entry hits from concurrent reuse when evaluating actual caching.
Store failure boundaries
- A cache-read error is treated as a miss. A write error does not create a new business error response, but storage did not succeed;
X-Cache: MISSorcache:writealone does not prove persistence. - MultiLevel
remoteTimeoutdoes not cover writes, invalidation, or refill TTL lookup.writePolicy: "both"waits for L2, whilelocal-first-async-remotewrites L1 then L2 asynchronously; remote failure may leave inconsistent layers. - Redis deletion, tag invalidation, and broadcast publication can throw. MultiLevel single-key deletion ignores L2 errors; batch/tag invalidation follows
remoteInvalidationErrors. A successful call does not universally prove every replica was invalidated. lease.onTimeout: "fetch"controls what to do when waiting for a lease owner times out; it does not imply that a connection error while acquiring a lease automatically fetches from origin. Do not assume all cache failures degrade transparently.
Vary Headers
Different request header values will generate different cache entries:
Allow all request headers to participate in the cache key:
vary: "*" can greatly increase entry count. Prefer listing the headers that actually affect content. It does not replace authentication, authorization, or a trusted partition.
Conditional caching
Use the condition function to control whether to use caching logic:
Custom Key
Fixed business key:
When you need to generate key according to request parameters:
A custom key replaces the default method/path/query combination; only partition and vary are still appended. This function is safe only if category is the sole input affecting content. Include pagination, sorting, or other parameters when relevant, or distinct requests can incorrectly share a result. Keeping the default key is usually simpler.
Partition Key
partitionKey is the cache partition. It does not change the business response, but only isolates the underlying cache keys by user, tenant, region and other dimensions.
This is a partial route configuration. First register auth middleware as described in Authentication and Security; it must validate credentials and populate trusted claims.tenantId. All viewers within a tenant must also be allowed to see the same list. Do not trust client-supplied x-tenant-id or x-user-id. The encoded partition enters the key, and the condition prevents caching without a trusted tenant. An Authorization request needs a nonempty partition, or an explicit allow option, to be cache eligible.
Automatic public headers are disabled here. Still verify that proxies do not independently cache personal or tenant responses. Authentication and authorization must run before caching. Declaring partitionKey does not authenticate, authorize, or prove that a response belongs to that partition.
If you confirm that the response is not relevant to the user, you can also enable it explicitly:
Most business interfaces recommend using partitionKey instead of directly opening allowAuthorizationCache.
Concurrent origin fetches
In one process, concurrent requests that pass the pre-request policy and have the same final key use response-cache-kit single-flight to share an origin result. The requester fetching from origin reports MISS; waiters reusing that result report HIT. Early bypass, failed origin fetches, and separate workers/instances change execution counts. Cross-process coordination requires a lease; expiry or onTimeout: "fetch" can still cause multiple origin fetches and cannot guarantee exactly-once business execution.
The current implementation merges in-flight origin fetches with the same key even if the final response is not stored because of private, no-store, or Set-Cookie. Waiters may receive the first body's result with HIT. Under default cacheControl: true, such waiter responses may also lose the original private/no-store header; the first response's Set-Cookie is not replayed.
For private data, session creation/mutation, or endpoints that must run independently on each call, use cache: false or a condition that excludes the request before cache entry. A header set only in the handler cannot prevent this in-flight sharing. The opening example returns shareable demonstration data and excludes Cookie requests up front.
Cache Hooks trace flow, but cache:miss currently fires before the underlying lookup, so a final HIT may have emitted it first. cache:write means a response was captured, not that storage succeeded. Combine final responses and runtime statistics when measuring hits; event counts alone are not an accurate hit rate.
Safety precautions
Authentication routing + cache: Requests with Authorization will not be written to the response cache by default. When you need to cache authentication interfaces, use partitionKey to explicitly isolate users or tenants.
The framework can warn when a cached route declares auth or has middleware with auth in its name without partition or authorization-cache settings. A warning does not prove the identity source is trusted or replace isolation tests. Choose a policy:
- Use
partitionKeyto isolate by user/tenant - Use
conditionto exclude requests that should not be cached - Set
allowAuthorizationCache: trueonly when the response is truly independent of the user
For an endpoint that serves both anonymous and authenticated users, after middleware reliably establishes identity, condition: (req) => !req.auth?.isAuthenticated && req.headers.cookie === undefined && !req.headers.authorization can cache only anonymous requests. On a login-required endpoint it would exclude every successful request; cache: false is clearer.
Troubleshooting and verification
Continue with Middleware order, Hooks, Cookies and Sessions, and Route API.