# Storage

> 

Every cached function and handler reads and writes through a single global storage instance. By default that's an in-memory `Map`, but you can replace it with any backend that implements the minimal `StorageInterface`.

## The Storage Interface

A storage only needs two methods, `get` and `set`. Both may be synchronous or return a promise:

```ts
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>;
}
```

- `get(key)` returns the stored value, or `null` when the key is missing.
- `set(key, value, opts)` stores a value. `opts.ttl` is a lifetime in **seconds** (optional).
- Setting a **nullish** value (`null` or `undefined`) via `set` **deletes** the entry. This is how invalidation reaches your backend, so make sure your `set` handles it.

## Custom Storage

Use `setStorage` to swap in your own backend. Here it is backed by Redis:

```ts
import { setStorage } 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);
  },
};

setStorage(redisStorage);
```

Because the interface is so small, [unstorage](https://unstorage.unjs.io) drivers (filesystem, Cloudflare KV, S3, Redis, and many more) drop in with a thin adapter:

```ts
import { setStorage } from "ocache";
import { createStorage } from "unstorage";
import redisDriver from "unstorage/drivers/redis";

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

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

<note>

`setStorage` replaces the storage globally — every cached function and handler uses it. Call it once at startup, before your first cached call.

</note>

## Memory Storage & LRU

The built-in storage is created with `createMemoryStorage`. It keeps at most **10 000** entries by default, evicting the least-recently-used entries once the ceiling is exceeded (LRU). Reading or writing a key marks it as most-recently-used.

Pass `maxSize` to change the ceiling, or set it to `Infinity` (or `0`) to disable eviction and grow unbounded:

```ts
import { createMemoryStorage, setStorage } from "ocache";

// Change the entry ceiling.
setStorage(createMemoryStorage({ maxSize: 50_000 }));

// Opt out of the ceiling entirely.
setStorage(createMemoryStorage({ maxSize: Infinity }));
```

Memory storage also honors the per-entry TTL: expired entries return `null` and are cleaned up automatically.

## Multi-tier Caching

The `base` option prefixes cache keys. Pass an **array** of prefixes to enable multi-tier caching:

- **On read**, each prefix is tried in order and the first hit wins.
- **On write**, the entry is stored under *every* prefix.

```ts
import { defineCachedFunction } from "ocache";

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

This suits layered setups (e.g. a fast local tier in front of a shared remote tier): reads prefer the nearest tier while every tier stays populated on writes. A single string (the default is `"/cache"`) keeps everything in one tier.

Invalidation and expiration operate across all tiers — see [Invalidation & Expiration](/guide/invalidation).
