Skip to main content

Persisting and Restoring Customer State

Customer state management.

The Quick Start creates customers and runs enforcement in a single process. In production, your process restarts — deployments, crashes, scaling events. When it does, all in-memory customer state is gone: meters, grants, overrides. This guide covers how to persist that state and restore it correctly, and how the workflow differs between the local engine and Cloud.


Cloud vs. local: understand the difference first

If you're using Limitr Cloud, customer state is stored and managed by Cloud. You don't snapshot to a database, you don't restore on startup, and you don't worry about state loss on process restart. Cloud is the source of truth and is turn-key.

What you still need in Cloud mode is to load the customer into the local policy before the first enforcement call. ensureCustomer() handles this — it checks whether the customer is loaded locally, fetches them from Cloud if not, and creates them if they don't exist anywhere yet.

If you're using the local engine, customer state lives entirely in-process. policy.customers() is your snapshot mechanism. You're responsible for persisting it and loading it on startup.


ensureCustomer() is the right call in both modes

Before any enforcement call for a customer, call ensureCustomer(). This is the preferred way to work with customers in Limitr — not createCustomer().

Here's what ensureCustomer() does, in order:

  1. Checks whether the customer is already loaded into the local policy. If yes — no-op, returns false.
  2. If a Cloud WebSocket is open, tries to fetch the customer from Cloud. If found — loads them locally, returns false.
  3. If not found in Cloud (or no Cloud connection), creates the customer locally and registers them with Cloud if connected. Returns true.
// Safe to call on every request — only does work when necessary
await policy.ensureCustomer(userId, 'starter');

createCustomer() has none of these guards. It will attempt to create the customer regardless of whether they already exist, and in Cloud mode it will not fetch an existing Cloud customer. Use ensureCustomer() everywhere except in explicit one-time provisioning flows where you know the customer is new.

The return value tells you whether a customer was just created — useful for running first-time setup only for new customers:

const isNew = await policy.ensureCustomer(userId, 'starter');
if (isNew) {
await policy.ensureCustomerIncludedTopups(userId);
await policy.ensureCustomerPlanQuantity(userId);
// add org seat, send welcome email, etc.
}

Local engine: persisting state

The local engine stores all customer state in-process. policy.customers() returns a complete snapshot — every customer's plan, meters, grants, overrides, and metadata.

const snapshot = await policy.customers();
// {
// 'user_abc': { id: 'user_abc', plan: 'growth', meters: {...}, grants: {...}, ... },
// 'user_xyz': { id: 'user_xyz', plan: 'starter', meters: {...}, ... },
// }

When to snapshot

On every enforcement event — the safest approach. Never lose more than one request worth of state. Use addHandler() to snapshot on meter-changed:

policy.addHandler('persist', async (key: string) => {
if (key === 'meter-changed') {
const snapshot = await policy.customers();
await db.set('limitr:customers', JSON.stringify(snapshot));
}
});

On a periodic schedule — acceptable for lower-traffic applications where losing a few seconds of meter state is tolerable. Simpler to reason about, cheaper to run:

setInterval(async () => {
const snapshot = await policy.customers();
await db.set('limitr:customers', JSON.stringify(snapshot));
}, 10_000); // every 10 seconds

On graceful shutdown — always snapshot on SIGTERM regardless of which strategy you use:

process.on('SIGTERM', async () => {
const snapshot = await policy.customers();
await db.set('limitr:customers', JSON.stringify(snapshot));
process.exit(0);
});

Where to store the snapshot

The snapshot is a plain JSON object. Any key-value store works.

Redis — The natural fit for most production stacks. Fast reads on startup, atomic writes, optional TTL for inactive customer cleanup.

await redis.set('limitr:customers', JSON.stringify(snapshot));

Postgres / your existing database — Fine if Redis isn't in your stack. A single jsonb column on a limitr_state table is sufficient.

await db.query(
'INSERT INTO limitr_state (key, value) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET value = $2',
['customers', JSON.stringify(snapshot)]
);

Filesystem — Reasonable for single-instance deployments. Not suitable for horizontally scaled services where multiple instances share state.


Local engine: restoring state on startup

Load the snapshot into the policy before you start handling requests. loadCustomers() accepts the record returned by policy.customers() or an array of customer objects.

import { Limitr } from '@formata/limitr';
import { readFileSync } from 'fs';

const policy = await Limitr.new(readFileSync('./policy.yaml', 'utf-8'), 'yaml');

// Restore persisted state before accepting traffic
const stored = await db.get('limitr:customers');
if (stored) {
await policy.loadCustomers(JSON.parse(stored));
console.log('Customer state restored');
}

// Now safe to handle requests
app.listen(3000);

loadCustomers() calls setCustomer() for each customer in parallel. It does not fire customer-set events — state restoration is not an event source.

What gets restored

Everything in the customer record: plan assignment, meter values and reset timestamps, grant balances and expiry, overrides, alt IDs, refs, and metadata. After loadCustomers(), the policy behaves as if the process never restarted.

What doesn't get restored

If the policy document changed between restarts — new plan, different limit values, renamed credits — and you load old customer state, the meters are still valid. Limitr resolves meters against the current policy at enforcement time. A customer with 400,000 tokens metered against a plan that now limits at 300,000 will be immediately at their limit, which is correct.

The case to watch: if you rename an entitlement (e.g. ai_tokensai_input), meters saved under the old name won't map to the new entitlement. This is another reason to choose stable entitlement names from the start — see Mapping Your Pricing Model to a Policy.


Cloud mode: startup workflow

In Cloud mode you don't restore from a snapshot — Cloud already has the state. Your startup sequence is simpler:

const policy = await Limitr.cloud({ token: process.env.LIMITR_TOKEN });
if (!policy) throw new Error('Cloud connection failed');

// That's it. Start handling requests.
// ensureCustomer() will load each customer on first access.
app.listen(3000);

The first enforcement call for any customer triggers a Cloud fetch if the customer isn't locally loaded. The customer is fetched, loaded locally, and all subsequent calls for that customer are in-process.

Warming the cache

If you have a small, known set of high-traffic customers, you can pre-load them at startup rather than waiting for the first request:

const policy = await Limitr.cloud({ token: process.env.LIMITR_TOKEN });

const activeCustomers = await db.query(
'SELECT id, plan FROM customers WHERE active = true LIMIT 1000'
);
await Promise.all(
activeCustomers.rows.map(row => policy.ensureCustomer(row.id, row.plan))
);

app.listen(3000);

This is optional — ensureCustomer() handles on-demand loading correctly. It's a latency optimization for the first request per customer, not a correctness requirement.

Disconnection behavior

If the Cloud WebSocket drops mid-traffic, ensureCustomer() for customers not yet loaded locally behaves according to denyUnconnected:

SettingBehavior
denyUnconnected: true (default)ensureCustomer() returns false without creating or fetching. Subsequent allow() calls deny for customers not locally loaded.
denyUnconnected: falseensureCustomer() creates the customer locally with the provided plan. When the connection restores, Cloud state re-syncs.

For most products, the default is correct. A customer who can't be verified against Cloud shouldn't be served as if they have a fresh plan with no consumption history.


Horizontally scaled deployments

In a multi-instance deployment, each process has its own in-memory policy state. With the local engine, each instance snapshots and restores independently — state is not shared between instances.

The simplest correct approach for horizontally scaled local deployments is sticky sessions by customer ID — route each customer to the same instance on every request. This keeps the meter state consistent without cross-instance coordination.

If sticky sessions aren't an option, you need a shared state strategy: snapshot to Redis on every meter change, and read from Redis as the source of truth for current meter values. This adds latency on every read.

At this point, use Cloud

If you're dealing with horizontal scale and can't use sticky sessions, Limitr Cloud is almost certainly the better answer. Cloud was designed for this case — the WebSocket syncs customer state across all connected instances automatically, with no Redis coordination layer required.