Stop Reaching for Redis, Postgres Can Do That
A second database is a second thing that can go down at 3am

Contents
Somewhere along the way, “add Redis” became the default reflex the moment an app needed caching, a job queue, or a pub/sub channel, regardless of whether the workload actually needed a separate in-memory store to get there. For a service handling genuinely enormous request volume, that reflex is earned. For most of what runs in a home lab or a small side project, it’s a second database to patch, back up, monitor and eventually debug at 3am, doing a job the Postgres instance already sitting there could do with a handful of extra lines of SQL. I’m not arguing Redis is bad software — it’s excellent at what it does. I’m arguing that reaching for it by default, before checking whether Postgres already covers the actual requirement, adds an operational cost that rarely gets weighed against the alternative.
What Redis is actually buying you
Redis earns its reputation honestly: it’s an in-memory data store with sub-millisecond latency, purpose-built data structures (sorted sets, hashes, streams), and a simple protocol that’s trivial to talk to from nearly any language. At genuinely large scale — hundreds of thousands of cache reads per second, a job queue processing at a rate where every millisecond of overhead compounds — that speed is the entire point, and nothing else comes close without a lot of engineering effort.
The honest question for a home lab or a small production app isn’t whether Redis is fast. It’s whether the workload is anywhere near the point where Postgres’s overhead — a slightly heavier protocol, disk-backed storage even for data that’s logically ephemeral — actually shows up as a bottleneck. For most self-hosted setups serving a household or a modest user base, the answer is no, and the second system exists purely as insurance against a scale that never arrives, at the cost of a second thing to operate.
Put a rough number on it: a well-tuned Postgres instance on ordinary homelab hardware comfortably handles low thousands of simple queries per second, with an UNLOGGED table read landing in single-digit milliseconds once the relevant pages are cached in shared buffers. A household running a handful of self-hosted apps, or even a small side project with a few hundred concurrent users, is nowhere near a request rate where that matters. Redis’s sub-millisecond figure is real, and it’s also frequently being compared against a Postgres number nobody in the conversation has actually measured on their own workload — the decision gets made on the general reputation of “Redis is faster,” not on whether the specific application would ever notice the difference.
Caching: UNLOGGED tables and a TTL you write yourself
The most common reason people reach for Redis is caching — storing a computed value with an expiry so it doesn’t need recomputing on every request. Postgres does this with an UNLOGGED table, which skips write-ahead logging for that table specifically, trading crash-safety (an unlogged table is truncated if Postgres crashes uncleanly) for write speed that’s much closer to an in-memory store than an ordinary table:
| |
The trade-off is genuine and worth naming honestly: Redis expires keys automatically and asynchronously in the background; Postgres needs something — a pg_cron job, or a check on the read path as shown above — to actually remove expired rows, and an unlogged table loses its contents on an unclean crash rather than persisting through it. For a cache, losing the contents on crash is usually fine, since the entire point of a cache is that it can be safely repopulated from the source of truth. What you gain is one fewer service, one fewer connection pool in your application, and cached data that lives in the same transactional boundary as the data it’s caching — which closes an entire class of consistency bug where a cache and a database disagree because two separate systems were updated in two separate operations.
Pub/sub: LISTEN/NOTIFY instead of a Redis channel
Postgres has had publish/subscribe built in for a very long time, in the form of LISTEN and NOTIFY. A client subscribes to a channel with LISTEN channel_name, and any connection — including a trigger firing on a table update — can broadcast a message to that channel with NOTIFY channel_name, 'payload'. Every listening client receives it in near real time.
| |
The ceiling here is real: NOTIFY payloads are capped at 8000 bytes, and Postgres pub/sub doesn’t durably persist messages for a subscriber that wasn’t listening at the moment of the broadcast, the way a proper message stream would. For “tell every connected client this row changed” at a scale a single household or small app actually produces, that ceiling is nowhere close to being hit, and you’ve avoided standing up a separate pub/sub broker for a feature that’s a few lines of trigger and a LISTEN call.
Job queues: SKIP LOCKED does what a dedicated queue does
This is the one people are most surprised by. A job queue needs exactly one guarantee that matters: two workers should never pick up the same job. Postgres’s FOR UPDATE SKIP LOCKED clause gives you precisely that, atomically, without a separate broker:
| |
Any worker running this query gets a job nobody else has locked, and moves past rows another worker already has locked rather than blocking on them. This is genuinely how several production job queue libraries are implemented under the hood — the database is the queue, with all of the durability guarantees a table already gives you for free: a crashed worker mid-job doesn’t lose the job, because it’s sitting in the table with a processing status and a timestamp you can use to detect and requeue stalled work, rather than living only in a Redis list that a network partition could have already dropped.
Stalled jobs — a worker that crashed mid-task without updating the row’s status — are the operational gap in this pattern, and they’re closed with a plain timestamp check rather than anything Redis-specific: a scheduled query that resets any job still marked processing past a reasonable timeout back to pending, so another worker picks it up on the next poll. A dedicated queue library handles this the same way under the hood; the difference is whether you can see and query that logic directly in SQL, or whether it’s hidden inside a client library’s retry configuration.
Session storage: the case sticky load balancing exists to avoid
Web session state is the other default reach for Redis, usually because it needs to be readable by any backend server handling a request, not just the one that created the session. A sessions table with a session_id primary key, a jsonb payload column and an expires_at timestamp does the same job, with the same UNLOGGED trade-off as the cache table above if session loss on an unclean crash is an acceptable risk — which for most home lab logins, where the worst case is asking a user to sign in again, it is. This is also the fix for the sticky-session problem that shows up in load balancing: once session state lives in a shared table rather than a single backend’s memory, any backend can serve any request regardless of which one issued the session, and the load balancer’s job goes back to being purely about even distribution rather than pinning clients to specific machines.
Rate limiting: a counter with a window, no different from a cache
Rate limiting needs a counter per key (per user, per IP) with a time window, incremented on each request and checked against a threshold. This is structurally identical to the caching problem above, and the same UNLOGGED table pattern handles it:
| |
One query, one round trip, atomic increment-and-read via ON CONFLICT. Redis’s INCR is marginally faster in isolation, and at genuinely high request volume — thousands of rate-limit checks a second — that difference compounds into something worth caring about. Below that threshold, it’s a query your Postgres instance was already handling connections for, with no additional infrastructure.
Where Redis is still the right answer
None of this is an argument that Redis is never worth running. If you’re doing session storage across a fleet of application servers at a scale where every millisecond of latency on every request matters, or you need Redis Streams’ specific consumer-group semantics, or you’re already operating at a request volume where Postgres’s connection overhead is a measured, real bottleneck rather than a theoretical one, Redis is the right tool and reaching for Postgres instead would be the mistake. The point isn’t “never Redis.” It’s “measure first” — and for the overwhelming majority of home lab and small-scale production setups, the workload never gets close to the point where the extra infrastructure pays for itself. Redis Streams’ consumer groups in particular do things SKIP LOCKED genuinely can’t — replaying a stream from an arbitrary offset, or fanning the same message out to multiple independent consumer groups without duplicating storage — and a workload that actually needs that shape of guarantee shouldn’t twist Postgres into approximating it badly.
Troubleshooting: the sharp edges of doing this in Postgres
The most common issue, and the one that catches people who moved from Redis expecting identical behaviour, is table bloat on a heavily updated cache or rate-limit table — every UPDATE in Postgres writes a new row version rather than modifying in place, and the old version needs to be reclaimed by autovacuum. A cache table under heavy churn can bloat faster than autovacuum’s default settings expect, which shows up as the table taking far more disk space than its actual row count would suggest. Tuning autovacuum more aggressively for that specific table (autovacuum_vacuum_scale_factor set low) or running a scheduled VACUUM keeps this in check — worth reading alongside a broader pass on tuning Postgres for a home lab’s actual workload if this is the first time autovacuum settings have come up.
The second is connection overhead if every cache read opens a new connection — Postgres connections are heavier than Redis’s, and an application hammering the cache table without a connection pool will feel that cost directly. A pooler (PgBouncer, or your application framework’s built-in pool) resolves this the same way it would for any other Postgres workload, and it’s a piece of infrastructure worth having regardless of whether you’re using Postgres as a cache.
The third is specific to LISTEN/NOTIFY: a subscriber that opens a connection, issues LISTEN, and then goes idle without ever reading from the socket will eventually have its notifications queued up server-side, and a client library that doesn’t poll for asynchronous notifications on an otherwise idle connection will simply never see them arrive despite the NOTIFY having fired successfully. This isn’t a Postgres bug so much as a mismatch between a purely request-response client library and a genuinely asynchronous feature — most languages’ Postgres drivers need an explicit event loop or polling call wired in for LISTEN to actually deliver anything, which is easy to miss the first time you reach for this pattern having only ever used the driver for ordinary queries.
The honest comparison, covered from the caching side specifically, is worth reading alongside this if you want the fuller picture of what a cache actually needs to guarantee and where Redis genuinely earns its place as a cache you understand rather than a reflex. Postgres won’t beat Redis on raw latency at scale. It will, for most home labs, remove a service you don’t need to be running at all — one fewer container to patch when a CVE lands, one fewer set of credentials to rotate, and one fewer dependency that has to be up before the rest of the stack can start.




