LazyLayersv0.6.3
Setups

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.

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

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

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
  })
}

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.

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

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

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.

Where to next

On this page