Storage

Every cached function and handler owns its storage. By default each instance gets a fresh in-memory store, so nothing is shared and nothing is global: two cached functions using the default storage can never see each other's entries — even with the same name and key — and independent libraries in one process cannot collide by accident.

To share a cache, pass the same storage to every function and handler that should use it. To persist entries, pass any backend that implements the small StorageInterface.

#The Storage Interface

A backend needs only two methods. Both may be synchronous or return a promise:

interface StorageInterface {
  get<T = unknown>(key: string): T | null | Promise<T | null>;
  set<T = unknown>(key: string, value: T, opts?: { ttl?: number }): void | Promise<void>;

  /** Optional: the largest byte charge one entry may have, if the backend enforces one. */
  maxEntryBytes?: number;

  /** Optional: set this only if the backend returns a stored `Uint8Array` as itself. */
  binary?: boolean;
}
  • get(key) returns the stored value, or null when the key is missing.
  • set(key, value, opts) stores a value. opts.ttl is an optional lifetime in seconds.
  • Setting a nullish value (null or undefined) deletes the entry. Invalidation reaches your backend through this path, so your set must handle it.
  • maxEntryBytes declares the backend's per-entry ceiling. Cached handlers derive their response-body limit from it, so bodies the backend could never store are refused before they are buffered.
  • binary declares that values come back with their byte views intact. Cached handlers then store a binary response body as a Uint8Array instead of base64 text, and cached functions do the same for a value that is bytes, which drops the encode on every store, the decode on every hit, and the 4/3 expansion in your backend. Only set this if it is true. A backend that serializes entries — JSON, and most persistent ones — returns a view as {"0":255,...}; ocache rejects a value in that shape as a miss rather than serving it, so a wrong declaration costs you every binary hit. Memory storage declares it, because it holds values by reference; for the same reason nothing may mutate a value it hands back.

#Custom Storage

Pass your backend as the storage option. A Redis example:

import { defineCachedFunction } from "ocache";
import type { StorageInterface } from "ocache";

const redisStorage: StorageInterface = {
  async get(key) {
    const raw = await redis.get(key);
    return raw === null ? null : JSON.parse(raw);
  },
  async set(key, value, opts) {
    // A nullish value means "delete this entry" (used by invalidation).
    if (value === null || value === undefined) {
      await redis.del(key);
      return;
    }
    // opts.ttl is in seconds — Redis' EX option expects seconds too.
    await redis.set(key, JSON.stringify(value), opts?.ttl ? { EX: opts.ttl } : undefined);
  },
};

const getUser = defineCachedFunction(fetchUser, { maxAge: 60, storage: redisStorage });

The interface is small enough that any unstorage driver — filesystem, Cloudflare KV, S3, Redis, and many more — fits behind a tiny adapter:

import { createStorage } from "unstorage";
import redisDriver from "unstorage/drivers/redis";

const unstorage = createStorage({ driver: redisDriver({/* ... */}) });

const storage: StorageInterface = {
  get: (key) => unstorage.getItem(key),
  set: (key, value, opts) =>
    value === null || value === undefined
      ? unstorage.removeItem(key)
      : unstorage.setItem(key, value, { ttl: opts?.ttl }),
};

setItem serializes the entry as JSON, so this adapter must not declare binary: a Uint8Array body would come back as {"0":255,...}. Binary bodies and binary function values take the base64 path here, which is what that path is for.

#Keeping bodies as bytes on a byte-only driver

Many drivers — fs, redis, s3, cloudflare-r2-binding, netlify-blobs, and others — implement getItemRaw/setItemRaw natively, and the rest fall back to base64 inside unstorage. Raw moves one byte payload, not an object graph: the fs driver writes the value with writeFile and reads it back with readFile, so handing it a cache entry throws ERR_INVALID_ARG_TYPE.

createBlobStorage adapts any such backend. It stores each entry as one frame — a small JSON header, then the entry's payload appended as bytes:

import { createBlobStorage } from "ocache";
import fsDriver from "unstorage/drivers/fs";

const unstorage = createStorage({ driver: fsDriver({ base: "./.cache" }) });

const storage = createBlobStorage({
  get: (key) => unstorage.getItemRaw(key),
  set: (key, value, opts) =>
    value === null ? unstorage.removeItem(key) : unstorage.setItemRaw(key, value, opts),
});

That is the whole adapter. An image lands on disk as its own bytes — no base64 anywhere, so no encode on a store, no decode on a hit, and no 4/3 expansion in the store. A text body is written as its UTF-8 bytes too, so it pays no JSON escaping in either direction. On a driver with no native raw support, unstorage base64-encodes the one blob for you, which is what the plain adapter above already paid.

Three things worth knowing:

  • The payload is declared, not detected. Cached handlers name the response body, and cached functions name a value that is bytes. A Uint8Array you bury somewhere else in a value is JSON on this backend, exactly as it would be on any other serializing one — pass payload: "value.body" if your own serialize produces a { body } shape.
  • A frame from another version reads as a miss. If the format changes, entries already in your store are re-resolved rather than misread. That costs one revalidation each.
  • binary is already declared for you. Do not wrap createBlobStorage in another adapter that unsets it.

Tip

A persistent backend outlives the process, so it needs deterministic keys across restarts. ocache's generated names (including the source-hash fallback) are stable, but review cache keys if you rely on anonymous functions.

#Sharing a Storage

Pass one instance to every cached function or handler that should share a cache:

const storage = createMemoryStorage();

const getUser = defineCachedFunction(fetchUser, { maxAge: 60, storage });
const getPosts = defineCachedFunction(fetchPosts, { maxAge: 60, storage });

Entries are still keyed by base, group, and name, so a shared backend does not mean shared entries — it means one connection, one eviction budget, and one place to purge.

Important

The name is what separates entries. It defaults to the function's or handler's name, falling back to a hash of the source. Instances produced by a factory or loop have identical source and therefore the same name — on shared storage they also share entries. Give such instances an explicit name.

#Late Binding

Handlers are usually defined at module load, but the real backend may not exist until the server starts. Pass a factory instead of an instance: ocache calls it on the first cache read or write — not at definition time — and at most once per cached function or handler.

let storage: StorageInterface;

const handler = defineCachedHandler(renderPage, {
  maxAge: 60,
  storage: () => storage, // resolved on first use, once
});

// ...later, at server start
storage = redisStorage;

Note

ocache writes the resolved storage back onto the options object you passed. The standalone invalidateCache() and expireCache() helpers need that original options object — or the same backend — and throw when options.storage is unset. resolveCacheKeys() only computes keys and needs no storage. See Invalidation & Expiration.

#Memory Storage & LRU

The default backend comes from createMemoryStorage — each cached function or handler gets its own. It holds at most 10 000 entries and 100 MB, and evicts the least recently used entries when either limit is exceeded. Reading or writing a key makes it most recently used.

The two limits control different things: maxSize caps the entry count, maxBytes caps memory. maxSize alone does not bound memory — retained bytes are maxSize × entry size, and for cached HTTP responses your pages set the entry size. 10 000 entries of 1 MB is 10 GB, and a process cannot catch an out-of-memory kill. Set a limit to Infinity or 0 to disable it:

import { createMemoryStorage } from "ocache";

// Change the ceilings.
const storage = createMemoryStorage({
  maxSize: 50_000,
  maxBytes: 512 * 1024 * 1024,
});

// Opt out of the entry ceiling, keep the byte budget.
const bigCache = createMemoryStorage({ maxSize: Number.POSITIVE_INFINITY });

const cached = defineCachedFunction(fn, { maxAge: 60, storage });

#How bytes are counted

The byte value is a deliberate overestimate — a budget that estimates low is not a reliable limit:

  • Strings (including cached response bodies) count 2 bytes per character, the upper bound an engine can retain.
  • A binary view charges the whole ArrayBuffer it keeps alive, even when it covers only part of it.
  • Key bytes and a small per-entry overhead are included.

Pass sizeOf to replace the estimate with your own full per-entry charge:

const storage = createMemoryStorage({
  maxBytes: 64 * 1024 * 1024,
  sizeOf: (value, key) => key.length + JSON.stringify(value).length,
});

#Refused entries

  • An entry larger than maxBytes by itself is not stored, and any previous value under its key is removed. The rest of the cache is not evicted to make room for something that still cannot fit — one huge response cannot wipe all other entries. Raise maxBytes if you cache a few very large values.
  • An immeasurable value is refused too — for example an object whose property getter throws. Storing it free of charge would defeat the budget. Provide sizeOf to cache such values at a charge you control.

Note

maxBytes also bounds what cached handlers buffer: a response body too large for one entry is refused while it streams, before the whole body sits in memory. See maxBodySize.

Memory storage honors per-entry TTLs: expired entries return null and are cleaned up automatically.

#Layered Storage

composeStorage combines several backends into one. The usual shape is a fast local layer in front of a shared remote one:

import { composeStorage, createBlobStorage, createMemoryStorage } from "ocache";

const storage = composeStorage([
  { storage: createMemoryStorage({ maxBytes: 64 * 1024 * 1024 }), ttl: 60 },
  createBlobStorage(redis),
]);

const cached = defineCachedHandler(handler, { maxAge: 3600, swr: true, storage });
  • Reads try each layer in order and stop at the first hit.
  • A hit from a later layer is promoted into every earlier one, so the next read is local.
  • Writes and deletes reach every layer, so invalidation is never partial.
  • A layer that throws is skipped, never fatal. A read falls through to the next layer and a write continues to the others, so a shared backend can be down while the local one still serves.

What the cache sees is one backend, so cache keys, entries, base, and every purge helper work exactly as they do with a single store. Layers are instances, not factories: for late binding, pass a factory that builds the whole stack (storage: () => composeStorage([...])).

#Per-layer lifetimes

ttl caps what the cache asks a layer to store, in seconds — keep an entry for an hour in Redis and a minute in memory:

composeStorage([
  { storage: createMemoryStorage(), ttl: 60 }, // never held longer than a minute
  createBlobStorage(redis), // whatever `maxAge` asks for
]);

It is also the only lifetime a promotion has, because a promotion carries no lifetime of its own. A layer without a cap holds a promoted entry until its own backend evicts it — for memory storage that is maxSize/maxBytes doing the work. This bounds occupancy, not freshness: whether an entry is still fresh is decided from the entry itself, so an over-long layer TTL costs memory, never a stale response.

#What the stack declares

A composed stack has to publish one binary and one maxEntryBytes, and the two rules differ:

  • binary is declared only when every layer declares it. One stored form is chosen for the whole stack, so the weakest layer decides. Both built-in backends declare it, so createMemoryStorage in front of createBlobStorage keeps bodies as bytes; adding a layer that serializes entries falls the whole stack back to base64.
  • maxEntryBytes is the largest layer's ceiling, and unset if any layer declares none. An entry only has to fit the layer that ends up holding it: a response too large for a small memory layer is refused by that layer alone — quietly, as it already is today — and served from the layer that can hold it.

#Options

composeStorage(layers, {
  promote: false, // do not write a later hit back to the earlier layers
  onError: (error, key) => report(error), // defaults to console.error
});

Promotions run in the background so a read never waits for them. A set for the same key waits for one first, so a purge cannot be undone by a promotion it raced.

#Multi-tier Keys

Storage layers are separate backends. base is the other axis: it prefixes every cache key, and an array of prefixes tiers the keyspace within one backend:

  • Reads try each prefix in order and stop at the first hit.
  • Misses write the entry under every prefix.
  • Revalidation writes the hit prefix and every earlier prefix, promoting the entry toward the front.
import { defineCachedFunction } from "ocache";

const cachedFetch = defineCachedFunction(
  async (url: string) => {
    const res = await fetch(url);
    return res.json();
  },
  {
    maxAge: 60,
    base: ["/edge", "/origin"], // read "/edge" first, then "/origin"; write both
  },
);

A single string keeps everything in one tier; the default is "/cache". Reach for composeStorage when the tiers are different stores, and for base when they are different namespaces in the same one. Invalidation and expiration always operate across all tiers of both — see Invalidation & Expiration.