Just use Postgres, until it actually hurts
"Just use Postgres" has been a slogan for a decade and it has always been slightly annoying, because it is usually said by someone who has not run the thing under load and is repeating it as a personality. I want to say it as someone who has, with the numbers, and with the specific conditions under which I would stop.
The product is a B2B tool with around 40,000 daily active users, a few hundred tenants, and a handful of background jobs that do the real work. The infrastructure is a Next.js app, a worker service, and one Postgres 18 instance with a replica. There is no Redis. There is no Kafka. There is no Elasticsearch, no Pinecone, no dedicated job queue. Every one of those things exists, in the form of a table and an index, in the same database that holds the customers.
That was a deliberate decision at the start and it has been re-examined at every step where it might have been wrong. So far it has not been.
The queue
Background jobs go in a table. A worker claims a batch with FOR UPDATE SKIP LOCKED, processes it, and marks it done, all in one transaction. I have written about this pattern at length because I built an extension around it, and the reasons it works are worth restating in one paragraph.
The job is enqueued in the same transaction as the business change that caused it. There is no window where the order exists and the job does not, or the reverse. When the worker fails mid-job, the transaction rolls back, the row is unclaimed, and the next worker gets it. Retries, backoff and dead lettering are columns and a WHERE clause. Throughput on this product is around 200 jobs a second at peak, on a table that is currently 90 million rows with the old ones partitioned off by month, and the claim query takes under a millisecond.
The thing people assume is the problem, contention on the queue table, is not, because SKIP LOCKED was built for exactly this and because workers are partitioned by a modulo on the id. The thing that is a real cost is that a job holding a transaction open for a long time holds a connection, and connections are the scarcest thing in Postgres. Jobs that call slow external APIs claim the row, commit, do the call outside the transaction, and then write the result in a second transaction. That is the one rule.
The cache
The cache is an UNLOGGED table with a key, a jsonb value and an expiry, and a partial index on the expiry so that the cleanup job can find dead rows fast. Unlogged means it skips the write-ahead log, which makes writes about as fast as a memory store and means the table is empty after a crash, which for a cache is correct behaviour.
Reads are a primary key lookup, a few hundred microseconds including the network. That is slower than Redis on the same network by a factor of two or three. It is also on the same connection the request already holds, so there is no second connection pool, no second failure mode, and no second thing to monitor. The cache hit rate on the hot paths is around 94 percent and the difference between 200 microseconds and 80 has never shown up in a p99.
The condition under which I would move this to Redis is a working set that no longer fits in shared buffers, because at that point the "cache" is reading from disk and the whole premise is gone. The working set is currently 3 GB against 16 GB of shared buffers. That number is on a dashboard.
Search
Full text search is the built in tsvector with a GIN index, and vector search for the semantic features is pgvector with an HNSW index. The two are combined in one query with reciprocal rank fusion for the hybrid case. I wrote about that pattern separately. The short version is that it is about 40 lines of SQL and it outperforms a vector only search on any corpus that contains proper nouns.
The document corpus is about 2 million chunks. The hybrid query returns in 30 to 60 milliseconds at p99. That is slower than a dedicated search engine would be, by perhaps a factor of three. It also updates in the same transaction as the document it indexes, so search is never stale, and there is no indexing pipeline to operate, and when the product was two years younger and had no search at all, adding it was a migration rather than a new piece of infrastructure.
The signal that would move this out is not query latency. It is index build time. An HNSW index on 2 million vectors rebuilds in about 20 minutes with the parallel builder, and at 20 million it would be hours. If the corpus grows by a factor of ten the index becomes something that needs its own machine, and that is a different product than this one.
The scheduler
Cron-style jobs use pg_cron. It is an extension, it runs inside the database, and it schedules SQL. A job that needs to run application code enqueues a row in the queue table, on a schedule, and the workers pick it up. There is no separate scheduler process to keep alive, no clock skew between the scheduler and the database, and the schedule is in a table that can be queried and changed with an UPDATE.
Analytics
Product analytics events go into a partitioned table, one partition per day, with a BRIN index on the timestamp because the data is append only and time ordered. Queries over the last 30 days scan 30 partitions and use the BRIN to skip most blocks. This is the piece that is closest to its limit, and the one I would move first.
The reason is that analytics queries are large scans, and large scans compete with the transactional workload for buffer cache and I/O. Postgres 18's asynchronous I/O made this much better than it was. The same 30 day query that took 6 seconds on 17 takes about 2.5 on 18 with io_method = worker, because the sequential reads are now issued ahead of the consumer. But it is still a workload that wants columnar storage and does not care about transactions, running on a system built for the opposite.
The signal here is a clear one: when an analytics query shows up in pg_stat_activity at the same moment as a p99 spike on the API, they are fighting, and it is time. That has happened twice, both times fixed by moving the heavy query to the replica, which is the first step out and a cheap one. The second step is DuckDB reading the partitions directly, which is on the list for the autumn. The third is a real warehouse, and I do not think this product gets there.
What it costs, and what it saves
The instance is 8 vCPU, 32 GB, with a replica the same size. The monthly cost is a few hundred dollars. The equivalent architecture with a managed Redis, a managed search cluster, a managed queue and a managed warehouse would be, from the quotes I have gathered when re-examining this, roughly five times that, before any engineering time.
The engineering time is the larger number. One database means one backup, one restore procedure, one set of credentials, one thing to upgrade, one connection pool, one place to look when something is slow. Every piece of infrastructure you add is a new failure mode that interacts with the existing ones, and the interactions are where the incidents live. A queue that is a table cannot get out of sync with the database, because it is the database.
The three signals
I said I would be specific about when to stop, so here they are, and each is a number on a dashboard rather than a feeling.
Connections. Postgres processes are expensive and the pool is finite. When the sum of what the app, the workers, the cache reads and the search queries need exceeds what one instance can serve through the pooler, the first thing to move is whichever piece holds connections the longest, which is usually the queue's slow jobs. Currently at about 40 percent of the pooler's capacity at peak.
Buffer cache. When the working set of any one piece, the cache table, the search index, the hot partitions, grows past the point where it fits in shared buffers alongside the others, that piece is now on disk and it wants its own memory. The cache table is the one to watch, at 3 GB of 16.
Interference. When a heavy query from one workload shows up in the same window as a latency spike in another, they are competing, and the heavy one moves to the replica first and out of the database second. Analytics is the one that does this.
None of the three has crossed the line yet. When one does, one piece moves, and the rest stay. That is the actual meaning of "just use Postgres": not that you never add anything, but that every addition has to earn its place with a number, and the number is usually a lot further away than the architecture diagrams suggest.