DEV Community

jamilxt
jamilxt

Posted on

Before You Add Kafka, Redis, and Elasticsearch: Try One Postgres First

An essay titled "PostgreSQL for Everything" by Raphael Bauer has been on the Hacker News front page this week, and its argument is simple: before you adopt a new specialized system, ask whether Postgres already does that job well enough. Contentful rebuilt their full-text search on Postgres instead of a separate search cluster. Instacart built a modern search infrastructure on Postgres. The Guardian famously migrated off MongoDB onto Postgres for parts of their platform.

Those are big companies, but the argument matters most for small ones. Say you are one developer with a Spring Boot app and a VPS, and you need three things beyond the main database: a job queue, a cache for repeated lookups, and search over text. The reflex answer is the usual stack: RabbitMQ or Redis Streams for the queue, Redis for the cache, Elasticsearch or Meilisearch for search. That is one app plus three more moving parts, each with its own Docker image, its own failure modes, its own 3 AM pager behavior. For a small system, that is hiring an orchestra to play a ringtone.

The alternative is the thing old-school database people keep telling us and we keep ignoring: do all three jobs in Postgres. A table with SKIP LOCKED for the queue. An unlogged table for the cache. A tsvector column with a GIN index for search. The whole thing is one schema, one backup strategy, one connection pool. The smaller your team, the more a single database that does five jobs is worth.

Here is the Spring Boot version of "Postgres for everything", with code you can lift, and with the places each pattern breaks as you grow called out along the way.

Full disclosure up front: this is a patterns piece assembled from the Postgres documentation and the engineering write-ups linked here, not a report from giant scale. Treat the "where it breaks" notes as the most important part, because some of these patterns do break.

Pattern 1: The job queue with SKIP LOCKED

The workhorse. You have a jobs table, workers poll it, and each job must be claimed by exactly one worker. The naive approach, SELECT then UPDATE WHERE status = 'pending', races between workers. Postgres has had the fix since 9.5: SELECT ... FOR UPDATE SKIP LOCKED. Each worker locks the rows it grabs, and rows already locked by another worker are simply skipped, so two workers never claim the same job. No advisory locks, no leader election, no broker. The Postgres docs cover this under row-level locking, and Crunchy Data has a good deep dive on queuing with native Postgres.

The entity:

@Entity
@Table(name = "jobs", indexes =
    @Index(name = "idx_jobs_pending", columnList = "status, runAt"))
public class Job {
    @Id @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    private String type;          // e.g. "send-email", "rebuild-index"
    private String status;        // pending, running, done, failed

    @Column(columnDefinition = "jsonb")
    private String payload;

    private Instant runAt;
    private Instant lockedAt;
    private int attempts;
}
Enter fullscreen mode Exit fullscreen mode

The claim query, as a native query in your Spring Data repository:

public interface JobRepository extends JpaRepository<Job, UUID> {

    @Query(value = """
        UPDATE jobs SET status = 'running', locked_at = now()
        WHERE id IN (
            SELECT id FROM jobs
            WHERE status = 'pending' AND run_at <= now()
            ORDER BY run_at
            FOR UPDATE SKIP LOCKED
            LIMIT :batch
        )
        RETURNING *
        """, nativeQuery = true)
    List<Job> claimJobs(@Param("batch") int batch);
}
Enter fullscreen mode Exit fullscreen mode

One statement. FOR UPDATE SKIP LOCKED inside the subquery means concurrent workers each get a disjoint batch, even if they run at the same instant. RETURNING * hands you the claimed rows without a second round trip.

Then a poller, which is just a scheduled method:

@Scheduled(fixedDelay = 2000)
public void poll() {
    List<Job> jobs = repository.claimJobs(5);
    jobs.forEach(processor::handle);   // handles retry/backoff on failure
}
Enter fullscreen mode Exit fullscreen mode

That is a durable job queue in about forty lines. Your jobs survive restarts because they live in a table you already back up. Compare that to bootstrapping RabbitMQ, writing consumers, configuring dead-letter exchanges, and explaining to your future self how prefetch works.

Where it breaks: this gives you at-least-once delivery with polling latency, not push, and not Kafka-style replayable ordered logs. If you need millions of jobs per minute, streaming semantics, or long retention for event replay, use a real broker. Bauer's advice in his essay is the right heuristic: start with Postgres, and only swap in Kafka or RabbitMQ when it demonstrably stops performing.

One more detail that completes the story in production: add a reaper. If a worker dies mid-job, rows stay running forever. A scheduled job that flips running rows older than some timeout back to pending (and bumps attempts) completes the story.

Pattern 2: The cache as an unlogged table

Everyone reaches for Redis here. But a cache has a defining property: it can be lost. Postgres has a table type for exactly that, UNLOGGED. Writes skip the write-ahead log, which is most of the write cost, so it gets you dramatically closer to cache-like latency while keeping the query language and tooling you already have. The CREATE TABLE docs spell out the trade: an unlogged table is not crash-safe, it gets truncated after a crash. For a cache, that is not a bug, it is a cold start.

CREATE UNLOGGED TABLE cache_entry (
    key        text PRIMARY KEY,
    value      jsonb NOT NULL,
    expires_at timestamptz NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

The Spring side is trivial, one repository method:

@Query(value = """
    SELECT value FROM cache_entry
    WHERE key = :key AND expires_at > now()
    """, nativeQuery = true)
String get(@Param("key") String key);
Enter fullscreen mode Exit fullscreen mode

TTL handling can be a trigger on write, as Martin Heinz describes in his Postgres-as-cache write-up, or the simpler version: a @Scheduled job deleting expired rows every minute. Lazy and unglamorous, and for a single application it is usually enough.

For a small workload, an indexed read from Postgres and a network hop to Redis are close enough that the difference rarely decides anything. Your workload will differ; measure yours before believing either claim.

Where it breaks: no pub/sub invalidation across app instances, no eviction policies as sophisticated as Redis LRU, and it is one machine's cache, not a shared cluster. The moment you have many app nodes hammering one Postgres for cache reads, you are spending your database's capacity on cache traffic. That is the point to graduate to Redis, not before.

Pattern 3: Full-text search without a search engine

This is the one with the strongest big-company evidence. Contentful replaced their search infrastructure with Postgres full-text search and wrote about the results. Instacart did the same for their search stack. The core idea is a generated column of type tsvector plus a GIN index, described in the Postgres text search docs.

First the schema:

ALTER TABLE article ADD COLUMN search_vector tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
        to_tsvector('english', coalesce(body, ''))
    ) STORED;

CREATE INDEX idx_article_search ON article USING GIN (search_vector);
Enter fullscreen mode Exit fullscreen mode

Title matches rank higher because of the A weight. Then query it from Spring:

@Query(value = """
    SELECT id, title, ts_rank(search_vector, query) AS score
    FROM article, websearch_to_tsquery('english', :q) AS query
    WHERE search_vector @@ query
    ORDER BY score DESC
    LIMIT 20
    """, nativeQuery = true)
    List<ArticleSearchHit> search(@Param("q") String q);
Enter fullscreen mode Exit fullscreen mode

websearch_to_tsquery accepts search terms the way users type them, quoted phrases included, instead of the arcane to_tsquery syntax. No data sync problem, because the index lives next to the data. On a corpus of a few thousand to a few million rows, queries come back fast enough that latency is not the constraint, and relevance is entirely acceptable for a site search box.

Where it breaks: no typo tolerance or semantic matching out of the box, and relevance tuning is nothing like Elasticsearch's. At tens of millions of documents or heavy faceting needs, a dedicated engine earns its keep. A few thousand to a few million rows with plain keyword search: Postgres wins on operations alone, one less system to keep in sync with your primary data.

Pattern 4: JSONB when you need a document store

The Guardian's migration story is instructive because they were running MongoDB at real scale and moved to Postgres partly to reduce operational surface. The jsonb type stores documents, indexes inside them with GIN, and queries them with path operators. In Spring Boot, annotate a field with @Column(columnDefinition = "jsonb"), map it with a Hibernate AttributeConverter, and you have schema-flexible documents inside your relational database.

A common use is payloads and tool outputs with shapes that drift as you experiment. The rule that keeps it sane is to keep the columns you actually query on as real typed columns. JSONB is a sharp knife: wonderful for "store this blob of shape X", risky as a foundation for a heavily queried data model where you lose constraints, foreign keys, and clear types. The pragmatic pattern is typed columns for what you filter and join on, JSONB for the parts that legitimately vary.

Pattern 5: The AI-era bonus, pgvector

This is the pattern with the clearest pull in 2026. The pgvector extension turns Postgres into a vector database with HNSW indexes, and Spring AI ships a PgVector vector store implementation, so your RAG embeddings can live in the same database as everything else. For a small setup, that means similarity search without running a separate Pinecone or Qdrant instance. Same rule applies: at serious scale or with heavy multi-tenant filtering, dedicated vector databases justify themselves. For thousands to low millions of embeddings, one Postgres is fewer systems to babysit.

When one Postgres is enough, and when it is not

Here is the decision checklist to run through before adopting any new data infrastructure. Copy it into your next design doc.

  • Job queue: Postgres with SKIP LOCKED is enough when you have up to a few thousand jobs per minute and can tolerate poll latency. Move to a broker when you need streaming, fan-out to external systems, or replayable event logs.
  • Cache: an unlogged table is enough for a single app or low read volume. Move to Redis when multiple nodes saturate the database with cache traffic or you need shared invalidation.
  • Search: Postgres full-text is enough for keyword search into the millions of rows. Move to a search engine when you need typo tolerance, semantic ranking, or heavy faceting.
  • Documents: JSONB is enough when the relational core stays relational. Move to a document database when documents are the entire model and you need its specific operational strengths.
  • Vectors: pgvector is enough for personal and small-team RAG. Move to a dedicated vector database at large scale or with complex filtering requirements.

The common thread: each swap should be triggered by a measured limit, not by an architecture diagram that looks more impressive with more boxes.

The takeaway

The trap the "Postgres for everything" argument warns about is starting with an architecture diagram that looks impressive because it has more boxes. A safer order of operations is to begin with Postgres doing every job from day one, and treat each specialized system as an extraction to be earned by evidence, a measured limit you actually hit. What makes a system production-grade is that you can operate it at 3 AM, and one database with good backups beats four systems you half-understand.

The honest caveat: "Postgres for everything" is a starting position, not a religion. The companies I cited above did not stop at Postgres because it was trendy; they measured, and Postgres met the bar for their workload at the time. Do the same measurement for yours.

Have you shipped a Postgres-only queue or replaced a search cluster with tsvector? What scale did it hold up to before you had to move on? I would genuinely like to know where the walls are, so drop your experience in the comments.

I write about Java, Spring Boot, and AI every week. Subscribe, it is free.


Top comments (0)