Response caching

VextJS provides declarative route-level response caching, configured through the cache field of the route options. When the cache is hit, parameter verification and handler execution are skipped, and the cached JSON response is returned directly.

Basic usage

Numeric abbreviation

The simplest configuration, specifying the cache validity period in milliseconds:

import { defineRoutes } from "vextjs";

export default defineRoutes((app) => {
  // cache for 60 seconds
  app.get("/products", { cache: 60_000 }, async (req, res) => {
    const products = await db.getProducts();
    res.json(products);
  });
});

Complete configuration

app.get(
  "/products",
  {
    cache: {
      ttl: 120_000, // cache for 120 seconds
      vary: ["accept-language"], // Different languages are cached separately
      tags: ["products"], // Tags (for batch invalidation)
      condition: (req) => !req.query.refresh, // condition cache
      cacheControl: true, //Set the Cache-Control header (default true)
    },
  },
  async (req, res) => {
    res.json(await db.getProducts());
  },
);

Explicitly disable

app.get("/realtime", { cache: false }, async (req, res) => {
  res.json({ timestamp: Date.now() });
});

Configuration options

RouteOptions.cache

FieldTypeDefault ValueDescription
ttlnumberCache validity period, in milliseconds, must be > 0
keystring | (req) => stringAutomatically generatedCustom cache key; partitionKey and vary will still participate in the final underlying key
condition(req) => booleanThe caching logic is only used when true is returned
varystring[] | "*"[]Request headers that participate in caching key; "*" means that all request headers are involved
partitionKeystring | (req) => stringUser, tenant or other business partition, used for isolation zone authentication or multi-tenant response
allowAuthorizationCachebooleanfalseWhether to still allow caching of requests with Authorization when there is no partitionKey
cacheControlbooleantrueWhether to set the Cache-Control response header
tagsstring[][]Cache tag, used for app.cache.invalidate(tag) batch invalidation

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.

// src/config/default.ts
export default {
  cache: {
    enabled: true, // Whether to enable route-level response caching (default true)
    defaultTtl: 60_000, //The default value when the route does not specify ttl, in milliseconds
    maxEntries: 1000, // Memory quick configuration: maximum number of cache entries
    maxMemory: 50 * 1024 * 1024, // Memory quick configuration: maximum memory usage bytes
    cleanupInterval: 30_000, // Memory quick configuration: periodic cleaning interval, 0 means only lazy cleaning
  },
};

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; if you need to adjust the underlying runtime, please configure cache.cacheHub.

config.cache field

FieldTypeDefault ValueDescription
enabledbooleantrueWhether to enable route-level response caching. When set to false, the cache middleware will not be installed and the Redis/MultiLevel connection will not be opened
defaultTtlnumber60000The default TTL when the route does not specify ttl, in milliseconds
maxEntriesnumber1000Memory mode quick configuration, effective when cacheHub is not configured or is Memory
maxMemorynumberMemory mode quick configuration, maximum memory usage bytes
cleanupIntervalnumber0Memory mode quick configuration, periodic cleaning interval; 0 means lazy cleaning only during access
cacheHubobjectMemoryUnderlying runtime configuration: Memory, Redis, MultiLevel, lease, distributed

Memory cacheHub

export default {
  cache: {
    defaultTtl: 60_000,
    cacheHub: {
      mode: "memory",
      maxEntries: 1000,
      maxMemory: 50 * 1024 * 1024,
      cleanupInterval: 30_000,
      enableStats: true,
    },
  },
};
FieldTypeDefault ValueDescription
mode"memory""memory"Use in-process Memory cache
maxEntriesnumber1000Maximum number of entries
maxMemorynumberMaximum memory usage bytes
cleanupIntervalnumber0Periodic cleanup interval, in milliseconds
enableStatsbooleantrueWhether to record statistical information
enabledbooleantrueWhether the underlying Memory Store is enabled

Redis cacheHub

export default {
  cache: {
    defaultTtl: 2_000,
    cacheHub: {
      mode: "redis",
      url: "redis://localhost:6379",
      deleteCommand: "unlink",
      lease: {
        waitForOwner: 1_000,
        onTimeout: "fetch",
      },
      distributed: {
        channel: "vext:response-cache",
      },
    },
  },
};

Redis mode is suitable for multiple instances to share response cache. When enabling Redis/MultiLevel in a business project, ioredis needs to be installed:

npm install ioredis
FieldTypeDefault ValueDescription
mode"redis"RequiredUse Redis to store response snapshots
urlstringredis://localhost:6379Redis URL
clientobjectExisting Redis-like client, advanced usage
metaKeyPrefixstringcache-hub default valuetag metadata key prefix
scanCountnumbercache-hub default valueSCAN batch size
deleteCommand"del" | "unlink"delDelete command; large value recommended unlink
leaseboolean | objectfalseCross-process coordination with key back to the source
distributedboolean | objectfalseDistributed pattern/tag failure broadcast

MultiLevel cacheHub

export default {
  cache: {
    defaultTtl: 60_000,
    cacheHub: {
      mode: "multi-level",
      memory: {
        maxEntries: 1000,
        cleanupInterval: 30_000,
      },
      redis: {
        url: "redis://localhost:6379",
      },
      writePolicy: "both",
      backfillOnRemoteHit: true,
      remoteTimeout: 50,
      lease: true,
    },
  },
};

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.

FieldTypeDefault ValueDescription
mode"multi-level"RequiredEnable L1 Memory + L2 Redis
memoryobject{}L1 Memory configuration
redisobject{}L2 Redis configuration
writePolicy"both" | "local-first-async-remote"bothWrite policy
backfillOnRemoteHitbooleancache-hub default valueWhether to backfill L1 after L2 hits
remoteTimeoutnumbercache-hub default valueL2 operation timeout in milliseconds
remoteInvalidationErrors"ignore" | "throw"cache-hub default valueL2 invalidation error handling
leaseboolean | objectfalseUse the Redis layer for cross-process back-to-source coordination
distributedboolean | objectfalseDistributed failure broadcast

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.

lease: {
  ttl: 500,
  waitForOwner: 1_000,
  pollInterval: 10,
  onTimeout: "fetch", // or "throw"
}

distributed is used to broadcast invalidation actions such as app.cache.invalidate(tag) and app.cache.clear() to other instances:

distributed: {
  redisUrl: "redis://localhost:6379",
  channel: "vext:response-cache",
  instanceId: "api-1",
}

Caching behavior

By default only GET / HEAD requests are processed, and successful responses sent via res.json() are captured. res.text(), streaming responses, downloads and redirects do not write to the response cache.

Response header

headervaluedescription
X-CacheHITcache hit
X-CacheMISSCache miss (first request or expiration)
Cache-Controlpublic, max-age=NN=TTL seconds when MISS, N=remaining seconds when HIT

Cache Key algorithm

The default key includes the request method, path, sorted query, partitionKey and vary request headers.

GET /products → GET:/products
GET /products?limit=10&page=2 → GET:/products?limit=10&page=2
GET /products (Accept-Language: zh-CN) → GET:/products|accept-language=zh-CN
  • Query parameters are automatically sorted (?b=2&a=1?a=1&b=2)
  • Requests with Authorization are not cached by default unless partitionKey is configured or allowAuthorizationCache: true is explicitly set
  • When you need to differentiate cache by user or tenant, use partitionKey first
  • When using a custom key, partitionKey and vary will still be appended to the underlying key

Scenarios without caching

  • 204 No Content response
  • Non-2xx status codes (3xx/4xx/5xx)
  • Response contains Set-Cookie
  • Response header contains Cache-Control: no-store or private
  • The request header contains Cache-Control: no-store or no-cache
  • With Authorization and no partitionKey / allowAuthorizationCache configured
  • Response not sent via res.json()
  • cache: false explicitly disabled
  • cache: 0 or negative value
  • condition returns false
  • Custom key returns empty string

Runtime API

Operate the cache in the route handler through app.cache:

// Invalidate batches by tag
app.post("/products", {}, async (req, res) => {
  await db.createProduct(req.body);
  await app.cache.invalidate("products"); // All caches with products tags are invalidated
  res.json({ created: true }, 201);
});

//Delete the specified key
await app.cache.delete("GET:/products");

//Clear all caches
await app.cache.clear();

// View statistics
const stats = app.cache.stats();
// → { entries: 42, hits: 128, misses: 31, hitRate: 0.805 }

app.cache.clear() clears the current vext response cache namespace. In Redis/MultiLevel mode, it will not perform a full Redis database clear.

Vary Headers

Different request header values will generate different cache entries:

app.get(
  "/products",
  {
    cache: {
      ttl: 120_000,
      vary: ["accept-language"],
    },
  },
  handler,
);
GET /products (Accept-Language: zh-CN) → independent cache
GET /products (Accept-Language: en-US) → independent cache

Allow all request headers to participate in the cache key:

app.get("/debug", { cache: { ttl: 10_000, vary: "*" } }, handler);

vary: "*" will significantly increase the number of cache entries and is generally only recommended for debugging, proxy pass-through, or interfaces that really require strong isolation.

Conditional caching

Use the condition function to control whether to use caching logic:

app.get(
  "/data",
  {
    cache: {
      ttl: 60_000,
      // Skip cache when taking refresh parameter
      condition: (req) => !req.query.refresh,
    },
  },
  handler,
);
curl /data # Go to cache
curl /data?refresh=1 # Skip the cache and execute the handler directly

Custom Key

Fixed business key:

app.get(
  "/products",
  {
    cache: {
      ttl: 60_000,
      key: "products:list",
      tags: ["products"],
    },
  },
  handler,
);

When you need to generate key according to request parameters:

app.get(
  "/profile",
  {
    cache: {
      ttl: 300_000,
      key: (req) => `profile:${req.headers["x-user-id"] ?? "anonymous"}`,
    },
  },
  handler,
);

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.

app.get(
  "/tenant/products",
  {
    middlewares: ["auth"],
    cache: {
      ttl: 60_000,
      key: "tenant:products",
      partitionKey: (req) => req.headers["x-tenant-id"],
      tags: ["products"],
    },
  },
  handler,
);

In the above example, even if multiple tenants access the same URL, different cache partitions will be written. Requests with Authorization will bypass the cache by default; Vext will only allow it to enter the response cache after partitionKey is configured.

If you confirm that the response is not relevant to the user, you can also enable it explicitly:

app.get(
  "/public-with-auth",
  {
    cache: {
      ttl: 60_000,
      allowAuthorizationCache: true,
    },
  },
  handler,
);

Most business interfaces recommend using partitionKey instead of directly opening allowAuthorizationCache.

Concurrently send back to the source

After the same cache key expires, if 100 requests arrive at the same time, Vext will execute the handler only once through the single-flight mechanism of response-cache-kit, and the remaining requests will wait for the same return-to-origin result to avoid cache breakdown. The request that actually executes the handler is MISS; the request that waits and reuses the same result will output HIT.

Safety precautions

Warning

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 detects this scenario and issues a warning on startup. Solution:

  • Use partitionKey to isolate by user/tenant
  • Use condition to exclude requests that should not be cached
  • Set allowAuthorizationCache: true only if the acknowledgment response is not relevant to the user
// Recommendation: Use partitionKey for tenant isolation
app.get(
  "/my-orders",
  {
    middlewares: ["auth"],
    cache: {
      ttl: 60_000,
      partitionKey: (req) => req.headers["x-user-id"],
    },
  },
  handler,
);

//Also: authenticated users do not go through the cache
app.get(
  "/products",
  {
    middlewares: ["auth"],
    cache: {
      ttl: 60_000,
      condition: (req) => !req.headers.authorization,
    },
  },
  handler,
);