Single process
L1 only, no Redis and no event bus. The smallest configuration that is still worth having.
One process, one cache, nothing to operate. No Redis, no broker, no event bus.
This is the right setup when exactly one process reads and writes the data, or when each process is allowed to hold its own view of it for a bounded window.
What you get for free
You are not switching any of this on. It is already running.
In-flight dedupe
A thousand concurrent callers for one cold key produce one loader call. The rest wait on the same promise.
An L1 cache
Created for you when you do not pass l1. It is an LRU with two dials and nothing else to decide.
The whole setup
There is no src/cache/redis.js and no src/cache/bus.js here. Those two files exist only once you have infrastructure to talk to. You write one file.
import { LazyLayersCache } from 'lazy-layers-cache'
export const cache = new LazyLayersCache({
// Explicit, so the next reader knows the shared tier is off on purpose
// rather than forgotten.
l2: false,
// Fallback lifetime for anything that does not set its own.
ttlMs: 60 * 1000,
levels: {
// The only two dials L1 has. How many entries it holds, and how long
// each one lives.
L1: { maxEntries: 10_000, ttlMs: 30 * 1000 },
},
// Remember "this does not exist" briefly, so a missing row is not a free
// pass to hammer the database.
negativeCache: { ttlMs: 10 * 1000, maxEntries: 5_000 },
// Give up on a loader that has stopped making progress.
timeouts: { softMs: 800, hardMs: 5000 },
// Serve the last known value while the origin is down.
failSafe: { staleTtlMs: 30 * 1000 },
})TypeScript users also want the value type in its own file, exactly as the quickstart lays it out.
export interface User {
id: string
email: string
plan: 'free' | 'pro' | 'enterprise'
updatedAt: string
}Reading through it
getOrSet is the whole API surface you need. It checks L1 and then runs your loader.
import { cache } from '../cache/index.js'
import { db } from '../db.js'
export async function getUser(id) {
return cache.getOrSet(`user:${id}`, async ({ signal } = {}) => {
const row = await db.users.findById(id, { signal })
return row ?? undefined // undefined records a negative entry
})
}
export async function updateUser(id, patch) {
const updated = await db.users.update(id, patch)
// After the write commits, never before. Until it commits the cached
// value is still the truth.
await cache.delete(`user:${id}`)
return updated
}The loader receives an AbortSignal that your timeouts actually fire. Pass it to your I/O or the timeout only stops the waiting, not the work.
Why these numbers
The default is 1,000. Keep this as a count guard even when the byte ceiling is the primary memory control. Raise it only when hit-rate evidence shows that many small entries are being displaced.
With one layer and no bus, this is your only staleness control. Thirty seconds bounds how wrong a cached value can be for anyone who is not the process that wrote it.
Short on purpose. This is the window in which a newly created record still looks absent.
How long a value stays servable after its TTL, for use when the loader fails or times out. Longer than this and a broken origin looks like a working one for too long.
Choosing a TTL
The question is not how long you can cache something. It is how wrong it can be before someone notices.
| Data | Suggested TTL | Reasoning |
|---|---|---|
| Reference data such as countries and currencies | Hours | Changes rarely, and a stale value is harmless |
| User profiles | 30 to 60 seconds | Visible to the person who just edited it, so keep it tight |
| Permissions and entitlements | Do not cache, or a few seconds | Stale means wrong access |
| Aggregates and counters | 10 to 60 seconds | Approximate by nature |
| Product catalogue | Minutes | Editors expect a short delay |
| Session lookups | Match the session lifetime | Invalidate explicitly on logout |
Sizing L1
maxMemory defaults to 20% of effective host or container memory and is shared by built-in L1 stores in one process. maxEntries remains a separate count guard. LRU remains the replacement order, with bounded frequency and size signals used for admission when space is tight.
Plan for encoded payload bytes plus estimated key and metadata overhead and bounded fail-safe snapshots. Decoded values returned to application code are outside cache-owned accounting.
| Typical value | Wire size | Budget per entry | 10,000 entries |
|---|---|---|---|
| Session or feature flag | ~200 B | ~800 B | ~8 MB |
| User record | ~2 KB | ~6 KB | ~60 MB |
| Rendered list page | ~20 KB | ~56 KB | ~560 MB |
The byte ceiling controls retained cache allocations, not process RSS. Codec scratch buffers, application values, V8 overhead, and delayed allocator release still require headroom.
Measuring it
A cache you cannot measure is a cache you cannot tune.
import { cache } from './index.js'
let hits = 0
let misses = 0
cache.on((event) => {
if (event.type === 'hit') hits++
if (event.type === '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)A hit rate below roughly 50% usually means one of three things. maxEntries is too small and you are evicting hot keys, the TTL is shorter than your traffic pattern, or the keyspace is genuinely too wide to cache.
Never use a cache key as a metric label. Keys are unbounded, and one high-cardinality label will take down your metrics backend before it takes down your cache.
What you give up
Everything on this page costs you nothing to run, and here is the bill.
L1 lives in the process heap. A deploy, a crash or a scale-in event throws away every entry, and the next wave of traffic goes straight to your origin. In-flight dedupe still collapses concurrent callers for the same key, so the herd is one request per key rather than one per caller, but it is still a cold start for every key.
cache.delete drops the key from this process. With no event bus there is nobody to tell. If a second process exists and shares the database, it keeps serving its own copy until that entry's TTL runs out.
With an event bus, a resolved getOrSet publishes the loaded value so every peer warms its own L1 for free. One server pays for the query and the rest do not. With a single process there is no fan-out, so every key costs one loader call per process.
L1 is the only layer. Every miss and every eviction lands on your origin directly, with nothing in between.
When you have outgrown it
Move on when any one of these is true.
You run more than one process against the same data
Two processes with independent L1s will disagree, and the length of the disagreement is your L1 TTL. This includes running under a process manager or Node.js clustering, because every worker is a separate process with its own heap and its own L1.
Your L1 TTL has to be shorter than your traffic can afford
If you are shrinking ttlMs to control staleness and watching your origin load climb, you need event-driven invalidation instead of expiry. That is the bus.
Cold starts are visible to users
If deploy time correlates with a latency spike or a database load spike, you need a tier that survives the restart. That is L2.
One key is expensive enough that duplicating the work across processes matters
Per-process dedupe stops N callers becoming N queries. It does not stop N processes becoming N queries. Fan-out over the bus does.
Each of these is solved by adding a shared L2 and an event bus, which is one Redis connection and two more files.