better-push
Operate in production

Cache

An optional cache that accelerates hot reads, shares rate limits, and carries realtime signals between instances.

better-push works with no cache at all, and that stays true. Adding one is a single config line that makes three things better at once:

  • Hot reads stop hitting Postgres. Every notify() reads the user's preferences and device list; every feed poll reads their unread count. Those change rarely and are read constantly.
  • Rate limits become accurate. Counters live in the cache, so a budget of 120 requests a minute means 120 across your whole deployment rather than 120 per replica.
  • Realtime crosses instances. The cache's pub/sub is the bus that lets the instance holding a user's SSE stream hear about a send that landed on a different instance - or in your worker process.

The cache is not a source of truth. Postgres stays authoritative, every read falls back to it, and a cache failure degrades performance rather than correctness.

Turning it on

src/push.ts
import { betterPush } from "@better-push/core";
import { drizzleAdapter } from "@better-push/core/adapters/drizzle";
import { webPush } from "@better-push/core/providers/web-push";
import { redis } from "@better-push/core/cache/redis"; 

export const push = betterPush({
  database: drizzleAdapter(db),
  providers: [webPush({ vapid })],
  session: getSession,
  cache: redis(process.env.REDIS_URL!), 
});

ioredis is an optional peer dependency, imported lazily on the first command:

npm install ioredis

Apps that configure no cache never need it installed, and if you forget it the error names the install command.

Bring your own client

Pass a client instead of a URL when your app already owns a tuned, clustered, or TLS-configured connection:

import Redis from "ioredis";

const client = new Redis(process.env.REDIS_URL!, { tls: {} });

export const push = betterPush({
  // ...
  cache: redis({ client }),
});

Whoever created a connection is responsible for ending it: push.close() ends connections better-push made and leaves yours open. Pub/sub always gets its own connection - a subscribed Redis connection refuses ordinary commands - created with client.duplicate() on the first subscribe unless you supply one:

cache: redis({ client, subscriber: client.duplicate() }),

Keys and channels are namespaced with keyPrefix, "bp:" by default, so a shared Redis instance stays legible:

cache: redis(process.env.REDIS_URL!, { keyPrefix: "myapp:push:" }),

memory()

import { memory } from "@better-push/core/cache/memory";

cache: memory(),

An in-process Map with no dependencies. It is a development, single-process, and test tool: everything it holds is visible to exactly one process, so rate limits are per instance, realtime reaches only tabs attached to that instance, and - the one that bites - a separate worker process cannot invalidate the web process's cached device list.

The degradation matrix

What a cache changes:

no cachememory()redis()
Hot readsevery read hits Postgrescached per instancecached, shared
Invalidationn/acorrect in-process onlycorrect everywhere
Rate limitsper instanceper instanceshared and accurate
Realtimein-process onlyin-process onlyacross every instance
Separate worker processfinedevice cache goes stale up to the TTL after the worker disables a tokenfine
Verdictany deploymentone process onlyany deployment

And what a queue changes. Everything works at every fold; only precision does:

CapabilityDB only+ dbQueue+ bullmq
Async delivery with retriesinline, no retriesyesyes, higher throughput
Scheduled sendsnot available - at throwsyes, ~poll-interval precisionyes, exact
Digestsyes, flushed by cron via run-pending or flushDigests()yes, ~poll-interval precisionyes, exact
Studio queue screenshows bp_job (usually empty)fullfull, read through the BullMQ inspector
Cancellation of a scheduled sendn/ayes, unless already claimedyes, unless already active
Push receipts (Expo)tickets are final; dead tokens pruned on the next sendfetched on the next poll after the delayfetched at the delay, exactly
delivered_at on push rowsnever setset from receiptsset from receipts

Scheduled sends are the one row that genuinely cannot degrade: without a queue there is nothing to hold the send, so at throws at the call site rather than delivering early. Digests are the opposite - their source of truth is a row in your Postgres, so cron alone is enough.

That worker row is the honest one. The worker is the process that disables a dead push token; with memory(), that invalidation never reaches the web instances, so sends keep targeting a dead token for up to 300 seconds. It fails softly - the provider rejects it again and the token is disabled again - but it is the reason memory() is not a production answer once you have two processes.

What is cached

RegionServesTTLDropped by
devicesthe active device list used by notify()300sdevice register, delete, disable
preferencesa user's stored preference rows300spreference writes
unreadthe unread count on the feed endpoint60snotification created, mark-read, read-all

The TTLs are constants, not configuration. They are a safety net rather than the correctness mechanism: with a shared cache an invalidation is visible to every instance immediately, and the TTL only bounds a window this design does not otherwise cover. A knob here would be a knob on how wrong your data may be, and nobody can answer that usefully at config time.

The HTTP device list (GET {basePath}/devices) is deliberately not cached: it is a rare, user-initiated read on a settings screen, and a second region over the same table would be a second thing to get wrong.

Why invalidation is not your problem

Caching is a decorator over the DatabaseAdapter, not cache calls sprinkled through the runtime. Nothing can mutate your database except through that object, so invalidation is structural rather than a discipline someone can forget:

  • notify(), the router, the dispatcher, the worker and the studio contain no cache code at all;
  • a delivery failure that disables a dead token drops that user's device list, even though the dispatcher has never heard of the cache;
  • a write inside a transaction drops its keys once, after the commit - and a rollback drops nothing, because nothing happened;
  • reads inside a transaction bypass the cache entirely, since a transaction's snapshot is not a fact that should outlive it.

Two places are knowingly inexact, both healed by the TTL and both harmless: retention pruning cannot know which users' unread counts it changed, and a device that changes owner leaves the previous owner's cached list stale.

Failure behaviour

Nothing on the send path may be slowed or broken by the cache:

  • a read that cannot reach the cache falls back to the database and logs;
  • a write that cannot invalidate still succeeds, and the TTL heals it;
  • a rate-limit counter that cannot be reached allows the request (a limiter that fails closed turns a Redis blip into an outage);
  • a realtime publish that fails is logged and never awaited on the send path.

Shutting down

process.on("SIGTERM", () => void push.close());

push.close() ends open SSE streams cleanly (so clients reconnect elsewhere rather than hang), releases the bus, stops the limiter's sweep timer, and closes cache connections better-push created. It is idempotent, and safe to call when no cache is configured.

Cache interfaces

Prop

Type

Prop

Type

On this page