---
title: Share one rate budget between workers with Redis
description: Several processes on one vendor key spend one rate budget instead of one each, using the Redis store that ships with outbound-sdk and the Redis client you already have.
sidebar:
  label: Shared rate budget
related:
  - /docs/rate-limits
  - /docs/retries
  - /guides/run-many-clients
---

The SDK keeps every request inside the vendor's rate limit, but by default it counts in the process. Run three workers on one Instantly key and each keeps its own budget, so together they can send three times what one would. On HeyReach, whose burst limit is 15 requests per 2 seconds, that is enough to draw 429s. This guide puts the count in Redis so every process on a key draws from one budget.

> **Preview: not published yet**
>
> `outbound-sdk` is not on npm, and its GitHub repository is private, so there
> is no install command that works today. Instantly and HeyReach are `beta`:
> their reads were run, read-only, against one live workspace each. Smartlead is
> `unverified`: written from its public documentation and tested offline only.
> See [what is verified](/docs/status).

## Before you start

- Node 22 or later, and a Redis you can reach from every worker: Redis, Valkey or Upstash.
- A Redis client you already use. The package ships none; you pass one function.
- **What was verified.** On 2026-11-14 the store was run by hand against a real Redis 7 server, not Valkey or Upstash: it gave the same answers as the in-memory store over a schedule of request times, a pause set by one caller blocked the key for the others, and two workers sharing a budget of 5 requests a second never reserved more than 5 in any second. Nobody has run it in production. The samples on this page run without Redis, and say so where that matters.

## The problem, shown

Two workers on one key, each making three reads. First with a store each, which is what two processes have by default, then with one store between them. `outbound.limits()` reports what each worker believes has been used.

```ts title="two-workers.ts"
import { createOutbound, MemoryRateLimitStore } from "outbound-sdk";
import type { OutboundApi, RateLimitStore } from "outbound-sdk";
import { heyreach } from "outbound-sdk/heyreach";

import { documentedFetch } from "./documented-fetch.ts";

// A worker is one process holding the client's key.
const worker = (rateLimitStore: RateLimitStore) =>
  createOutbound({
    adapter: heyreach({ apiKey: "the-same-key" }),
    fetch: documentedFetch,
    rateLimitStore,
  });

const used = async (outbound: OutboundApi): Promise<string> => {
  const [snapshot] = await outbound.limits();

  return (snapshot?.windows ?? [])
    .filter((window) => window.lane === "default")
    .map(
      (window) =>
        `${window.used} of ${window.requests} per ${window.perMs / 1000}s`
    )
    .join(", ");
};

// Each process counting for itself, which is the default.
const first = worker(new MemoryRateLimitStore());
const second = worker(new MemoryRateLimitStore());

for (const outbound of [first, second]) {
  await outbound.campaigns.list();
  await outbound.campaigns.list();
  await outbound.campaigns.list();
}

console.log("separate stores");

console.log("  worker 1 sees:", await used(first));

console.log("  worker 2 sees:", await used(second));

// One store between them. In production this is redisRateLimitStore();
// one object here stands in for it, because both use the same interface.
const shared = new MemoryRateLimitStore();
const third = worker(shared);
const fourth = worker(shared);

for (const outbound of [third, fourth]) {
  await outbound.campaigns.list();
  await outbound.campaigns.list();
  await outbound.campaigns.list();
}

console.log("one shared store");

console.log("  worker 1 sees:", await used(third));

console.log("  worker 2 sees:", await used(fourth));
```

```text title="Output"
separate stores
  worker 1 sees: 3 of 7 per 2s, 3 of 150 per 60s
  worker 2 sees: 3 of 7 per 2s, 3 of 150 per 60s
one shared store
  worker 1 sees: 6 of 7 per 2s, 6 of 150 per 60s
  worker 2 sees: 6 of 7 per 2s, 6 of 150 per 60s
```

With separate stores each worker thinks 3 of its 7 requests are used, while HeyReach has seen 6. With one store both see 6, and the next request but one would wait.

The sample uses one in-memory store object to stand in for Redis, because both implement the same `RateLimitStore` interface, and a fetch that answers with HeyReach's documented campaign list:

```ts title="documented-fetch.ts"
// A stand-in for HeyReach: it answers every request with the campaign list
// from HeyReach's documentation (fixtures/heyreach/listCampaigns.json in the
// repository). It is not a live response.
import { readFile } from "node:fs/promises";

const documented = await readFile(
  new URL("../../../../fixtures/heyreach/listCampaigns.json", import.meta.url),
  "utf-8"
);

export const documentedFetch: typeof fetch = () =>
  Promise.resolve(
    new Response(documented, {
      headers: { "content-type": "application/json" },
    })
  );
```

## Use the Redis store

`redisRateLimitStore` takes `evaluate`, a function that runs a Lua script on your client and resolves with its reply. The comment shows the line for each common client.

```ts title="worker.ts"
import { createOutbound } from "outbound-sdk";
import { instantly } from "outbound-sdk/instantly";
import { redisRateLimitStore } from "outbound-sdk/redis";
import type { RedisEvaluate } from "outbound-sdk/redis";

// The package ships no Redis client. `evaluate` runs a Lua script on yours:
//
//   ioredis:    (script, keys, args) => redis.eval(script, keys.length, ...keys, ...args)
//   node-redis: (script, keys, args) => client.eval(script, { keys, arguments: args })
//   Upstash:    (script, keys, args) => redis.eval(script, keys, args)
export const createWorker = (evaluate: RedisEvaluate) =>
  createOutbound({
    adapter: instantly(),
    rateLimitStore: redisRateLimitStore({
      evaluate,
      // Redis unreachable: this process counts for itself until it is back.
      onError: (cause) => {
        console.error("rate budget is not shared right now:", cause.message);
      },
      prefix: "acme:outbound",
    }),
  });
```

Create every worker this way and they share the budget. Nothing else changes: the same reads, the same `meta.throttledMs`, the same errors.

| Option | Default | Meaning |
| --- | --- | --- |
| `evaluate` | required | Runs a Lua script: `(script, keys, args) => Promise<reply>` |
| `prefix` | `outbound-sdk:rate` | Prepended to every key |
| `onError` | none | Called when Redis cannot be reached |

## What it keeps in Redis

One sorted set per credential and lane, holding one member per request in the longest window, and one key per paused lane. Both expire on their own. This sample prints the keys for an Instantly worker without a Redis:

```ts title="redis-keys.ts"
import type { RedisEvaluate } from "outbound-sdk/redis";

import { createWorker } from "./redis.ts";

// Not a Redis: it prints the keys the store would use on one, and answers 0.
const printing: RedisEvaluate = (_script, keys) => {
  console.log(keys.join("\n"));

  return Promise.resolve(0);
};

// limits() asks the store how much of each lane's budget is used.
await createWorker(printing).limits();
```

```text title="Output"
acme:outbound:{instantly:c018c41c1afaf2c0b66c64f9}:log
acme:outbound:{instantly:c018c41c1afaf2c0b66c64f9#emails}:log
acme:outbound:{instantly:c018c41c1afaf2c0b66c64f9#labelPrediction}:log
acme:outbound:{instantly:c018c41c1afaf2c0b66c64f9#localBusinessFilters}:log
acme:outbound:{instantly:c018c41c1afaf2c0b66c64f9#localBusinessSearch}:log
```

- **The API key is not in the key name.** The credential is named by the provider and a hash of the key.
- **Each lane has its own log**, such as Instantly's 20 per minute on `GET /emails`.
- **The braces are a Redis Cluster hash tag**, so a credential's keys live on one node and the script may touch them together.

Each reservation is one script call, so two processes can never both take the last slot.

## When Redis is down

The store falls back to counting in the process and calls `onError`. Reads keep working and each process still limits itself; what is lost is the sharing. When Redis is back the next request uses it again. Log `onError` so you know when the budget is not shared.

| What happened | What you get | What to do |
| --- | --- | --- |
| Redis unreachable | Requests go on, counted per process. `onError` is called. | Alert on `onError`. Lower `share` if an outage would push you over the vendor's limit. |
| The shared budget is full | The call waits, and reports the wait in `meta.throttledMs` | Nothing, or give the call a `deadline` |
| The budget is full past the call's `deadline` | `RateLimited` with `timedOut: true` and `retryAfterMs`, before any request | Run later, or raise the deadline |
| The vendor answers 429 anyway | The lane is paused in Redis for every worker, then the request is retried | Something else is spending the key's limit. Lower `share`. |
| `evaluate` returns something that is not a number | It is read as 0: the request goes | Check the adapter line for your client |

## Limits

- **Sharing covers the count and the pause after a 429.** The slowdown that follows a 429 (the rate halves, then climbs back) is kept per process.
- **Request times come from each worker's clock.** Keep clocks in step with NTP. A worker whose clock is far ahead takes slots early.
- **One Redis round trip per request.** For reads that take tens of milliseconds at the vendor this is small, but it is not free.
- **A budget spaces reservations, not arrivals.** Network jitter can bunch requests at the vendor. The default share of one half leaves room for that.
- **Another store** (Cloudflare KV, a Durable Object, a database) works if you implement `reserve`, `pause` and `usage` and make `reserve` atomic. KV has no atomic check-and-set, so a Durable Object is the right fit on Cloudflare. None ships with the package.
