LazyLayersv0.5.3
Setups

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.

src/cache/index.js
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.

src/types/user.ts
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.

src/users/repository.js
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

levels.L1.maxEntries(10_000)

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.

levels.L1.ttlMs(30_000)

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.

negativeCache.ttlMs(10_000)

Short on purpose. This is the window in which a newly created record still looks absent.

failSafe.staleTtlMs(30_000)

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.

DataSuggested TTLReasoning
Reference data such as countries and currenciesHoursChanges rarely, and a stale value is harmless
User profiles30 to 60 secondsVisible to the person who just edited it, so keep it tight
Permissions and entitlementsDo not cache, or a few secondsStale means wrong access
Aggregates and counters10 to 60 secondsApproximate by nature
Product catalogueMinutesEditors expect a short delay
Session lookupsMatch the session lifetimeInvalidate 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 valueWire sizeBudget per entry10,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.

src/cache/metrics.js
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.

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.

Where to next

On this page