The most common production pattern. Memory serves hot data with sub-millisecond latency; Redis provides shared persistence across instances. Each layer manages its own TTL:
import { CacheManager, MemoryAdapter } from "@ziggurat-cache/core";
import { RedisAdapter } from "@ziggurat-cache/redis";
import Redis from "ioredis";
const userCache = new CacheManager({
namespace: "users",
layers: [
new MemoryAdapter({ defaultTtlMs: 30_000 }), // L1: 30s
new RedisAdapter({
client: new Redis(process.env.REDIS_URL),
prefix: "myapp:",
defaultTtlMs: 300_000, // L2: 5min
}),
],
});How it works:
wrap("42", factory)→ checks memory forusers:42→ checks Redis → calls factory- On a Redis hit, memory is auto-backfilled with memory's own 30s TTL (capped by whatever life the Redis entry has left)
- On a factory call, memory gets 30s TTL, Redis gets 5min TTL
For high-availability setups, you can add a third layer as a final fallback:
const cache = new CacheManager({
namespace: "products",
layers: [
new MemoryAdapter({ defaultTtlMs: 15_000 }), // L1: 15s
new RedisAdapter({
client: primaryRedis,
prefix: "app:",
defaultTtlMs: 300_000,
}), // L2: 5min
new RedisAdapter({
client: fallbackRedis,
prefix: "app:",
defaultTtlMs: 3600_000,
}), // L3: 1hr
],
});When a cache miss in L1 hits in a lower layer, Ziggurat automatically copies ("backfills") the value to higher layers. The syncBackfill option controls whether the calling code waits for that backfill to complete.
With syncBackfill: false (the default), the value is returned immediately while higher layers are populated in the background via a fire-and-forget promise.
const cache = new CacheManager({
layers: [memory, redis],
// syncBackfill: false — this is the default
});Trade-off: A second request arriving before backfill completes will hit L2 again. In practice, this is rare because in-process memory backfill completes in microseconds.
Best for: Most production workloads where latency matters more than absolute consistency between layers. Examples:
- Web API endpoints serving user data: the response shouldn't wait for memory backfill when the value was already found in Redis
- High-throughput pipelines where every millisecond counts: the duplicate L2 hit on a rare race is cheaper than always waiting
With syncBackfill: true, the get() and mget() calls await backfill completion before returning. This guarantees that after the call, all higher layers contain the value.
const cache = new CacheManager({
layers: [memory, redis],
syncBackfill: true,
});
// After this line, `memory` is guaranteed to have the value
const entry = await cache.get("key");Best for scenarios where layer consistency is critical:
-
Test suites — Deterministic behavior makes assertions reliable. Without sync backfill, tests that check L1 state after a get() may see stale results depending on timing.
-
Read-then-act workflows — When subsequent logic depends on the value being in L1. For example, a batch job that loads a configuration from Redis and then issues thousands of lookups against it:
const configCache = new CacheManager({
layers: [memory, redis],
syncBackfill: true, // ensure config is in memory before the hot loop
});
const config = await configCache.get("batch-config");
// Memory is guaranteed warm — the next 10,000 get() calls hit L1
for (const item of items) {
const cfg = await configCache.get("batch-config"); // always L1
process(item, cfg);
}| Scenario | Recommended | Why |
|---|---|---|
| Web API endpoints | async (default) |
Lower latency; rare race is acceptable |
| Background workers | async (default) |
Throughput > consistency |
| Test suites | sync |
Deterministic assertions |
| Read-then-act hot loops | sync |
Avoid repeated L2 hits |
| Cache warming on startup | sync |
Ensure layers are populated before serving |
With coalescing enabled (the default), concurrent callers share a single factory invocation:
// 100 concurrent requests, 1 database query
const results = await Promise.all(
userIds.map((id) =>
cache.wrap(`user:${id}`, () => db.users.findById(id), 300_000),
),
);Note: Coalescing is per-key. Different keys run their factories independently in parallel. Only concurrent misses for the same key are coalesced.
Disable coalescing when you need each caller to get a fresh factory invocation, such as when debugging or testing concurrent behavior:
const cache = new CacheManager({
layers: [new MemoryAdapter()],
stampede: { coalesce: false },
});When a factory throws with coalescing enabled, the error propagates to all coalesced callers:
const results = await Promise.allSettled(
Array.from({ length: 10 }, () =>
cache.wrap("key", async () => {
throw new Error("DB is down");
}),
),
);
// All 10 promises are rejected with the same error
// The in-flight entry is cleaned up — next call retries the factoryEvery CacheManager instance emits typed events with zero overhead when no listeners are attached. Use these to build dashboards, alerting, or custom metrics:
const cache = new CacheManager({
layers: [memory, redis],
});
// Track hit rate
let hits = 0;
let misses = 0;
cache.on("hit", () => hits++);
cache.on("miss", () => misses++);
setInterval(() => {
const total = hits + misses;
if (total > 0) {
console.log(`Hit rate: ${((hits / total) * 100).toFixed(1)}%`);
}
hits = 0;
misses = 0;
}, 60_000);The @ziggurat-cache/otel package automatically translates cache events into OTel counters and histograms. It depends only on @opentelemetry/api (the lightweight API package) — your application provides the SDK and exporter.
npm install @ziggurat-cache/otel @opentelemetry/apiimport { CacheManager, MemoryAdapter } from "@ziggurat-cache/core";
import { RedisAdapter } from "@ziggurat-cache/redis";
import { instrumentCacheManager } from "@ziggurat-cache/otel";
const cache = new CacheManager({
layers: [
new MemoryAdapter({ defaultTtlMs: 30_000 }),
new RedisAdapter({ client: redis, defaultTtlMs: 300_000 }),
],
});
// Start recording — metrics flow to your configured OTel backend
const cleanup = instrumentCacheManager(cache, { meterName: "my-app" });
// Use cache normally
await cache.wrap("user:42", () => db.users.findById(42));
// On application shutdown
cleanup();This records counters (ziggurat.cache.hit, ziggurat.cache.miss, ziggurat.cache.error, etc.) and histograms (ziggurat.cache.duration, ziggurat.cache.wrap.factory_duration) with attributes like cache.layer and cache.operation. See the API Reference for the full list.
If you don't use OpenTelemetry, subscribe to events directly and push to any metrics system:
// Prometheus via prom-client
import { Counter } from "prom-client";
const cacheHits = new Counter({
name: "cache_hits_total",
help: "Cache hits",
labelNames: ["layer"],
});
const cacheMisses = new Counter({
name: "cache_misses_total",
help: "Cache misses",
});
cache.on("hit", (e) => cacheHits.inc({ layer: e.layerName }));
cache.on("miss", () => cacheMisses.inc());// StatsD
import StatsD from "hot-shots";
const client = new StatsD();
cache.on("hit", (e) => client.increment("cache.hit", { layer: e.layerName }));
cache.on("miss", () => client.increment("cache.miss"));
cache.on("wrap:miss", (e) =>
client.histogram("cache.factory_duration", e.factoryDurationMs),
);Use colons to create a hierarchy. This makes keys readable and allows pattern-based operations at the Redis level:
`user:${userId}:profile``user:${userId}:preferences``product:${productId}:details``product:${productId}:reviews:page:${page}`;When your data schema changes, version your cache keys to avoid deserializing stale data:
const CACHE_VERSION = "v2";
await cache.wrap(
`${CACHE_VERSION}:user:${id}`,
() => db.users.findById(id),
300_000,
);Vary TTL based on the data characteristics:
async function getProduct(id: string) {
const ttl = isHighDemandProduct(id) ? 60_000 : 600_000;
return cache.wrap(`product:${id}`, () => fetchProduct(id), ttl);
}Ziggurat handles layer failures gracefully. If Redis goes down, memory still serves cached data and new misses go straight to the factory:
Normal: get(L1) → miss → get(L2) → miss → factory → set(L1, L2)
Redis down: get(L1) → miss → get(L2) → ERROR → skip → factory → set(L1, L2*)
* L2 set silently fails via Promise.allSettled
When Redis recovers, new factory results are written to both layers again. There's no manual intervention needed.
For critical paths, you can wrap the entire cache call in a try-catch and fall back to a direct fetch:
async function getUserProfile(id: string) {
try {
return await cache.wrap(`user:${id}`, () => db.users.findById(id), 300_000);
} catch (error) {
// Factory itself failed (not a cache issue)
logger.error("Failed to fetch user profile", { id, error });
throw error;
}
}By default, set/mset/delete/mdel never throw — a layer failure (or even all layers failing) is only observable through "error" events, so register an error listener in production. To make a total write failure throw instead, enable strict writes:
const cache = new CacheManager({ layers, strictWrites: true });
// throws AggregateError if every layer rejects the writestrictWrites applies to direct set/mset/delete/mdel calls. wrap() is unaffected: it always returns the value your factory computed, even if caching that value fails — the write error still surfaces via "error" events. In a single-layer setup, any write failure means "every layer failed", so it throws.
By default wrap() resolves only after the computed value has been written to every layer, so a read issued right after it is guaranteed to see the value. The cost is that a slow layer adds its full write latency to every miss — and to every caller coalesced onto that miss. A Redis instance that is degraded rather than down is the case that hurts: it accepts writes, slowly, and each wrap() miss waits for them.
Set wrapWrites: "background" to resolve as soon as the factory does and let the layer writes settle afterwards:
const cache = new CacheManager({
layers: [memory, redis],
wrapWrites: "background", // default is "await"
});The trade-off is a brief window where wrap() has returned but the value is not cached yet, so a request arriving inside that window recomputes it. Write failures still surface as "error" events either way. Keep the default when correctness depends on read-your-write behavior (a wrap() immediately followed by a get() on the same key); choose "background" when miss latency matters more.
MemoryAdapter stores live references by default, while the Redis, SQLite, and Memcache adapters JSON round-trip values. A Date survives an L1 hit but comes back as an ISO string when L2 serves the same key. If you cache rich types in a multi-layer setup, either store plain JSON-safe data or set new MemoryAdapter({ serialization: "json" }) for consistent shapes (this also prevents callers from mutating cached objects in place). Note that in json mode, non-serializable values (functions, circular references) throw at set() time and undefined is not stored.
The MemoryAdapter uses TTL-based expiration via node-cache. Set defaultTtlMs on the adapter to automatically expire entries:
new MemoryAdapter({ defaultTtlMs: 30_000 }); // entries expire after 30 secondsWithout defaultTtlMs, entries never expire unless a TTL is passed via set/wrap. For production workloads with unbounded key spaces, always set a defaultTtlMs to prevent memory growth:
// OK: finite set of config keys
const configCache = new MemoryAdapter();
// Better: unbounded user IDs with TTL-based cleanup
const userCache = new MemoryAdapter({ defaultTtlMs: 60_000 });MemoryAdapter: expired entries are evicted lazily on access. For write-heavy workloads setcheckPeriodMs(periodic eviction) and/ormaxKeys; callclose()on shutdown ifcheckPeriodMsis set.SQLiteAdapter: expired rows are removed lazily on access. CallpurgeExpired()periodically in long-running processes.
Place the fastest layer first. Each layer is queried sequentially — if L1 has the value, L2 is never consulted:
// Correct: fast → slow
layers: [new MemoryAdapter(), new RedisAdapter({ client: redis })];
// Wrong: slow → fast (Redis is always checked first)
layers: [new RedisAdapter({ client: redis }), new MemoryAdapter()];Not everything benefits from caching. Consider:
- Cache: Expensive queries, external API calls, computed aggregations
- Don't cache: Fast lookups, writes, data that changes on every request
| Data Type | Suggested TTL | Rationale |
|---|---|---|
| User profiles | 5-15 minutes | Changes infrequently |
| Product listings | 1-10 minutes | Moderate change rate |
| Search results | 30-60 seconds | Changes often, short-lived value |
| Configuration | 5-30 minutes | Rarely changes |
| Real-time data | Don't cache | Stale data is harmful |
Ziggurat includes functional tests that run against real cache backends. These are separated from the default hermetic test suite and require explicit invocation.
# No external services needed
pnpm testAll unit, contract, and integration tests pass without Redis or any other backend running.
# Redis
export REDIS_URL=redis://localhost:6379
pnpm test:functional:redis
# Memcached
export MEMCACHE_URL=localhost:11211
pnpm test:functional:memcache
# SQLite (no external service needed)
pnpm test:functional:sqliteIf you don't have backends installed locally, start them via Docker Compose:
# Start Redis
docker compose --profile redis up -d
# Start Memcached
docker compose --profile memcached up -d
# Configure environment — copy .env.example then run tests
# (vitest functional configs auto-load .env from repo root)
cp .env.example .env
# Run functional tests
pnpm test:functional:redis
pnpm test:functional:memcache
pnpm test:functional:sqlite
# Stop backends when done
docker compose --profile redis --profile memcached downpnpm test:functionalThis runs functional tests for all configured backends (Redis, Memcached, SQLite).
Functional tests run automatically in GitHub Actions via the functional jobs in .github/workflows/ci.yml. Each backend is provisioned as a service container and tested in a separate matrix job, reporting results independently from the hermetic test suite.
To add functional tests for a new adapter (e.g., Postgres):
- Create test files under
packages/<adapter>/tests/functional/ - Add
vitest.functional.config.tsin the adapter package - Add
test:functionalscript to the adapter'spackage.json - Add a profile to
docker-compose.yml - Add the env var (e.g.,
POSTGRES_URL) to.env.example - Add a matrix entry to the
functionaljob in.github/workflows/ci.yml - Add
test:functional:<adapter>to the rootpackage.json