---
title: cache
description: Serve a repeat read from a store for a set number of seconds, with the store interface, a custom store, and what a cached answer says about itself.
sidebar:
  label: cache
related:
  - /docs/rate-limits
  - /docs/plugins/failover
  - /docs/api/results
---

`cache({ ttl })` from `outbound-sdk/cache` keeps the answer to each read in a store and serves it again for `ttl` seconds, to save rate budget. The store is yours. The default is an in-memory store in this process. A cached answer keeps the time it was originally fetched in `meta.fetchedAt`. A failed read is not stored, and neither is an answer marked `stale`.

## Sample

The sample moves a clock forward and counts the reads that got past the cache. It runs on the [shared seed](/docs/plugins#every-plugin).

```ts title="run.ts"
import { createOutbound } from "outbound-sdk";
import type { OutboundPlugin } from "outbound-sdk";
import { cache } from "outbound-sdk/cache";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";

// One clock for the SDK and the cache, so the sample can move time forward.
let now = Date.parse("2026-11-14T12:00:00.000Z");
const clock = (): number => now;

// Listed after the cache, so it counts only the reads the cache let through.
let reads = 0;

const counter: OutboundPlugin = {
  name: "counter",
  wrap: async (operation, next) => {
    reads += 1;

    return await next(operation);
  },
};

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  plugins: [cache({ now: clock, ttl: 60 }), counter],
  runtime: { now: clock },
});

const show = async (label: string): Promise<void> => {
  const page = await outbound.campaigns.list();

  console.log(
    `${label}: ${page.items.length} campaigns, fetched ${page.meta.fetchedAt}, reads so far ${reads}`
  );
};

await show("first read ");

now += 30_000;

await show("30 s later ");

// A different question is a different entry.
await outbound.campaigns.list({ status: "paused" });

console.log(`another filter: reads so far ${reads}`);

now += 30_000;

await show("60 s later ");
```

```text title="Output"
first read : 3 campaigns, fetched 2026-11-14T12:00:00.000Z, reads so far 1
30 s later : 3 campaigns, fetched 2026-11-14T12:00:00.000Z, reads so far 1
another filter: reads so far 2
60 s later : 3 campaigns, fetched 2026-11-14T12:01:00.000Z, reads so far 3
```

The read 30 seconds later was served from the store, with the first read's `fetchedAt`. A different filter is a different entry. At 60 seconds the entry is as old as `ttl`, which counts as expired, so the read went through again.

## Options

| Option | Type | Default | Meaning |
| --- | --- | --- | --- |
| `ttl` | `number` | required | How long an answer may be reused, in seconds. `0` or less turns the cache off. |
| `store` | `CacheStore` | `memoryCacheStore()` | Where entries are kept. |
| `only` | operation kinds | every operation | Cache only these operations, for example `["campaigns.get", "stats"]`. |
| `now` | `() => number` | `Date.now` | The clock, for tests. |

The operation kinds are the names in `Operation["kind"]` from `outbound-sdk`: `campaigns.list`, `campaigns.get`, `leads.list`, `leads.get`, `messages.list`, `replies.list`, `senders.list`, `senders.get`, `sequence.get`, `stats`, `workspaces.list`, and the account reads `blocklist.list`, `campaigns.settings`, `lists.list`, `senders.health`, `sequence.subsequences`, `tags.list` and `webhooks.list`.

## What is cached

One entry holds one operation's output: one page of a listing, one lookup or one stats read. The key is a hash of the adapter's `provider`, its `workspaceId` and the operation with all its arguments, so:

- Another page, another filter, another date range or another id is another entry.
- `campaigns.get("cmp_1")` and a get by the same campaign's ref are two entries.
- Two instances that share a store and have the same provider and workspace label share entries.
- A page is cached with the continuation to its next page, so a walk over cached pages makes no request.

Per-call options (`timeout`, `retries`, `maxRequests`) are not part of the key.

## The store

A store is three async functions. `set` receives the time to live in milliseconds, so a store such as Redis can expire the entry itself. The plugin also checks an entry's age when it reads one, so a store that never expires anything is still correct.

| Function | Called |
| --- | --- |
| `get(key)` | Before every cached operation. Return `undefined` for a miss. |
| `set(key, entry, ttlMs)` | After every successful read that was not served from the store, unless the answer is marked `stale`. |
| `delete(key)` | Never by the plugin. It is there for you. |

An entry is `{ output, storedAt }`: the operation's output and the fetch time as an epoch in milliseconds. Both are plain data that survives `JSON.stringify`.

```ts title="store.ts"
import { createOutbound } from "outbound-sdk";
import { cache, cacheKey } from "outbound-sdk/cache";
import type { CacheEntry, CacheStore } from "outbound-sdk/cache";
import { memory } from "outbound-sdk/memory";

import { seed } from "../plugins-index/seed.ts";

// A store is three async functions. This one keeps entries in a Map and
// honours the time to live itself, as Redis or a database table would.
const rows = new Map<string, { entry: CacheEntry; expiresAt: number }>();

const store: CacheStore = {
  delete: (key) => {
    rows.delete(key);

    return Promise.resolve();
  },
  get: (key) => {
    const row = rows.get(key);

    return Promise.resolve(
      row === undefined || row.expiresAt <= Date.now() ? undefined : row.entry
    );
  },
  set: (key, entry, ttlMs) => {
    rows.set(key, { entry, expiresAt: Date.now() + ttlMs });

    return Promise.resolve();
  },
};

const outbound = createOutbound({
  adapter: memory({ data: seed, workspaceId: "agency" }),
  // Cache only the campaign lookups.
  plugins: [cache({ only: ["campaigns.get"], store, ttl: 300 })],
});

await outbound.campaigns.list();

await outbound.campaigns.get("cmp_founders");

console.log("entries:", rows.size);

// The key for one read on one credential, to drop an entry by hand.
const key = cacheKey(
  { provider: outbound.provider, workspaceId: outbound.workspaceId },
  { id: "cmp_founders", kind: "campaigns.get" }
);

console.log("stored under this key:", rows.has(key), key.slice(0, 9));

await store.delete(key);

console.log("entries after delete:", rows.size);
```

```text title="Output"
entries: 1
stored under this key: true outbound:
entries after delete: 0
```

`memoryCacheStore(maxEntries = 500)` is the default store. It holds entries in a `Map` and drops the oldest one written once it is full. It does not remove expired entries on its own. `cacheKey(scope, operation)` returns the key for one read.

## What it changes

| Field | On a cache hit |
| --- | --- |
| `meta.fetchedAt` | When the answer was originally fetched, not now. |
| `meta.requests` | Requests this call made. `0` for a single read served from the store. |
| `meta.stale` | `false`. Only [`failover`](/docs/plugins/failover) sets it, and a stale answer is never stored. |

Nothing else in `meta` says an answer came from the cache. Compare `fetchedAt` with the clock when you need to know its age. It changes no capability.

## With other plugins

- **The store holds what the layers inside the cache returned.** With `redact` listed before `cache`, as recommended, the store holds unmasked records and masking happens on the way out. Give the store the same protection as the vendor data itself.
- Layers inside the cache (`audit`, `tracing`, `budget`, `alerts`) do not run on a hit.
- Layers outside it (`redact`, `clients`, `identity`) run on every read.
- `failover` takes the same `CacheStore` interface and prefixes its keys with `failover:`, so the two can share one store.
- With `failover` inside the cache, an answer it serves during an outage is passed on with `stale: true` and its original `fetchedAt`, and is not stored. The next read tries the vendor again.
- A layer inside the cache that sets `fetchedAt` on its answer is believed: the entry is stored with that time, and `ttl` counts from it.

## When it fails

| What happened | What you get | What to do |
| --- | --- | --- |
| The read fails | The error. Nothing is stored. | Nothing. The next read tries again. |
| `store.get` or `store.set` rejects | The read fails with that error, even when the vendor answered | Catch errors inside your store and return `undefined` from `get` if a cache outage should not fail reads. |
| The vendor's data changed inside `ttl` | The old answer, with its original `fetchedAt` | Lower `ttl`, limit the cache with `only`, or call `store.delete(cacheKey(...))`. |
