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
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 ioredisApps 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 cache | memory() | redis() | |
|---|---|---|---|
| Hot reads | every read hits Postgres | cached per instance | cached, shared |
| Invalidation | n/a | correct in-process only | correct everywhere |
| Rate limits | per instance | per instance | shared and accurate |
| Realtime | in-process only | in-process only | across every instance |
| Separate worker process | fine | device cache goes stale up to the TTL after the worker disables a token | fine |
| Verdict | any deployment | one process only | any deployment |
And what a queue changes. Everything works at every fold; only precision does:
| Capability | DB only | + dbQueue | + bullmq |
|---|---|---|---|
| Async delivery with retries | inline, no retries | yes | yes, higher throughput |
| Scheduled sends | not available - at throws | yes, ~poll-interval precision | yes, exact |
| Digests | yes, flushed by cron via run-pending or flushDigests() | yes, ~poll-interval precision | yes, exact |
| Studio queue screen | shows bp_job (usually empty) | full | full, read through the BullMQ inspector |
| Cancellation of a scheduled send | n/a | yes, unless already claimed | yes, unless already active |
| Push receipts (Expo) | tickets are final; dead tokens pruned on the next send | fetched on the next poll after the delay | fetched at the delay, exactly |
delivered_at on push rows | never set | set from receipts | set 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
| Region | Serves | TTL | Dropped by |
|---|---|---|---|
| devices | the active device list used by notify() | 300s | device register, delete, disable |
| preferences | a user's stored preference rows | 300s | preference writes |
| unread | the unread count on the feed endpoint | 60s | notification 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