Serverless
L2 caching in short-lived Node.js processes and functions.
Serverless platforms give you a fresh process for every request or a short-lived container that disappears after handling a batch. L1, the in-process LRU, lives and dies with that process. By the time the next request arrives, the cache is gone.
That does not make LazyLayers useless. It makes it a different shape. You keep L2, you keep in-flight dedupe, and you keep the event bus. You skip L1, or you treat it as a single-request optimisation rather than a persistent layer.
What changes
L1 is gone or ephemeral
No process survives long enough for an LRU to warm across requests. Each cold start is truly cold for L1.
L2 is your real cache
Redis or Cloudflare KV outlives each invocation. A miss checks L2, then your origin. See the Cloudflare KV setup for Workers and Node.js.
In-flight dedupe still works
Concurrent callers within one invocation share one loader call. That is free, and it matters when a single request fans out to many parallel reads.
The bus connects instances
A bus lets active instances invalidate their local L1 copies promptly when those copies matter. Deleting a key from a shared Redis L2 does not need a bus.
The setup
You write two files, not four. There is no src/cache/bus.js unless you need cross-instance invalidation, and there is no L1 configuration because there is no persistent process to hold it.
Connect to Redis
One connection, one file. The same Redis client that backs L2 also backs the event bus if you use one.
import Redis from 'ioredis'
export const redis = new Redis(process.env.REDIS_URL, {
maxRetriesPerRequest: 2,
enableReadyCheck: true,
enableOfflineQueue: true,
})
redis.on('error', (err) => {
console.error('[redis]', err.message)
})Build the cache
L1 is off. L2 is the only persistent layer. The TTLs are longer because L2 is invalidated directly rather than by expiry.
import { LazyLayersCache, RedisStore } from 'lazy-layers-cache'
import { redis } from './redis.js'
export const cache = new LazyLayersCache({
// No L1. Every read goes to L2 or the origin.
l1: false,
// Fallback lifetime for anything that does not set its own.
ttlMs: 5 * 60 * 1000,
levels: {
// Long, because L2 is the only tier and is invalidated directly.
L2: { ttlMs: 60 * 60 * 1000 },
},
l2: new RedisStore(redis, {
prefix: 'app:cache:',
deleteStrategy: 'unlink',
scanCount: 500,
}),
// Optional. A bus is useful if other instances keep local L1 copies.
// eventBus: bus,
timeouts: { softMs: 800, hardMs: 5000 },
failSafe: { staleTtlMs: 30 * 1000 },
negativeCache: { ttlMs: 10 * 1000, maxEntries: 5_000 },
})Read and invalidate
The same getOrSet and delete API. A Redis L2 deletion is visible to other Redis clients because they share the same server. If other instances also keep local L1 copies, those copies need an event bus or a short TTL.
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
})
}
export async function updateUser(id, patch) {
const updated = await db.users.update(id, patch)
await cache.delete(`user:${id}`)
return updated
}Adding cross-instance invalidation
When serverless instances keep local L1 copies, a Redis L2 deletion does not immediately clear those in-process copies. Add a bus if those copies need prompt peer invalidation. With L1 disabled as above, deleting the shared Redis key is sufficient for the next read from Redis.
import { RedisEventBus } from 'lazy-layers-cache'
import { redis } from './redis.js'
export const bus = new RedisEventBus(redis, 'lazylayers.invalidate', {
retryQueue: { maxSize: 1000 },
handlerConcurrency: 16,
})
await bus.connect()Then wire it into the cache:
import { LazyLayersCache, RedisStore } from 'lazy-layers-cache'
import { redis } from './redis.js'
import { bus } from './bus.js'
export const cache = new LazyLayersCache({
l1: false,
ttlMs: 5 * 60 * 1000,
levels: {
L2: { ttlMs: 60 * 60 * 1000 },
},
l2: new RedisStore(redis, {
prefix: 'app:cache:',
deleteStrategy: 'unlink',
scanCount: 500,
}),
eventBus: bus,
source: process.env.FUNCTION_NAME ?? `fn-${process.pid}`,
timeouts: { softMs: 800, hardMs: 5000 },
failSafe: { staleTtlMs: 30 * 1000 },
negativeCache: { ttlMs: 10 * 1000, maxEntries: 5_000 },
})Platform notes
Vercel
Vercel serverless functions are short-lived containers. Each invocation is a separate process with its own heap. L1 would only help within a single invocation, which is too brief to matter.
Set source from process.env.VERCEL_FUNCTION_NAME or process.env.AWS_LAMBDA_FUNCTION_NAME if you are on the AWS-backed runtime.
Redis connections are not reused across invocations on Vercel unless you use a connection pool external to the function. Every cold start opens a new one. This is normal and the library handles it.
Cloudflare Workers
Use the Workers-safe lazy-layers-cache/cloudflare entrypoint with a KV binding. The Node.js entrypoint imports native compression packages and cannot be bundled into a Worker. See Cloudflare KV for a Hono Worker and a Node.js REST setup.
L1 is not meaningful in Workers. Each request is isolated, and there is no shared heap.
AWS Lambda
Lambda functions are containers that may be reused across invocations. L1 can persist between warm invocations, but it is unreliable as a cache because Lambda can freeze and resume on a different instance, or evict the container at any time. Treat L1 as a bonus for warm starts, not a guarantee.
Set source from process.env.AWS_LAMBDA_FUNCTION_NAME. If you use provisioned concurrency, L1 becomes more predictable, but L2 is still the tier you design around.
Netlify Functions
Same model as Vercel. Short-lived, no persistent process. L2 only, with an optional bus for cross-instance invalidation.
What you give up
Without a persistent process, L1 only helps within a single invocation. That is in-flight dedupe, not a cache. The real cache is L2.
A delete on one invocation drops the key from L2 on that Redis connection. Other invocations see the stale entry until the L2 TTL expires. Add a bus if you need immediate cross-instance invalidation.
Every cold start skips L1 entirely. The first read of every key pays a Redis round trip. With a bus, warm invocations may have the value in L2 from a previous load, but there is no local cache to absorb it.
Each cold start opens a new Redis connection. On platforms that reuse containers (warm Lambda), the connection may survive, but do not design around it.
When to move to serverful
Move to a serverful setup when any of these is true.
L1 hit rate matters
If your read pattern is the same keys repeatedly and you need sub-millisecond reads, a persistent L1 on a long-running process gives you that. Serverless cannot.
You need tight staleness control without a bus
On a single server, L1 TTL is your staleness window. On serverless, L2 TTL is, and it is longer because L2 is your only tier.
Connection limits are a problem
Many concurrent invocations each opening their own Redis connection can hit limits. A serverful process multiplexes requests over a single connection pool.