Everyone told us we were wrong. Six months later: p99 latency 11s → 2.4s, infra bill down 68%, on-call quieter. Here's the trade-off analysis nobody let us skip.
When we started unstructred, we did the respectable thing and put Kafka between every stage. Documents in, parse jobs out, results onward. It's what you do — a queue means you're serious. Two years later we deleted almost all of it and replaced it with Postgres tables, and this post is our confession plus the receipts.
Kafka is built for high-throughput, fan-out streaming: many consumers, each needing the same events, replayed from wherever. Our workload is nothing like that:
The core is boring, which is the point. One table, roughly:
CREATE TABLE parse_jobs ( id uuid PRIMARY KEY, document_id uuid NOT NULL, state text NOT NULL, -- queued | running | partial | done | failed attempt int NOT NULL DEFAULT 0, lease_expires timestamptz, enqueued_at timestamptz NOT NULL DEFAULT now() );
Workers claim jobs with the classic pattern:
UPDATE parse_jobs SET state = 'running', attempt = attempt + 1, lease_expires = now() + interval '5 minutes' WHERE id IN ( SELECT id FROM parse_jobs WHERE state = 'queued' OR (state = 'running' AND lease_expires < now()) ORDER BY enqueued_at FOR UPDATE SKIP LOCKED LIMIT 10 ) RETURNING *;
FOR UPDATE SKIP LOCKED does the load balancing: workers grab whatever isn't already claimed and never block each other. The lease expiry is the crash detector — a worker that dies mid-job has its row released automatically five minutes later, and attempt tells the next worker what happened. Total code: one UPDATE, one index. Total new infrastructure: zero.
Blobs never went through the queue in the first place — object storage then, object storage now. Only the pointers move.
| Metric | Kafka | Postgres |
|---|---|---|
| p99 end-to-end pipeline latency | 11.0 s | 2.4 s |
| p50 end-to-end pipeline latency | 2.1 s | 0.9 s |
| Infra bill (queue-related) | 100% | 32% |
| Components on-call pages touched | 4 | 1 |
| Rebalancing incidents | ~1/quarter | 0 (nothing to rebalance) |
The latency win isn't Postgres being magic — it's removing hops. Produce, commit, consumer poll, rebalance, consume: each step added tail latency, and tail latency is what p99 is made of. A single indexed table lookup doesn't have those tails.
The bill is the same story with money. Broker nodes sized for the 9am thunderstorm, running 24/7, doing almost nothing at 4pm. Postgres was already there, already sized, already backed up.
This isn't a free lunch, and we'd be lying by omission if we stopped here:
UPDATE jobs SET state='queued' WHERE ...) but it's heavier, and there's no clever offset arithmetic. We use it maybe twice a year. Fine trade.If none of those describe you, a table with SKIP LOCKED will hold more than you think, and you already know how to operate it. That last part — "already know how to operate it" — is worth more than any benchmark.
Questions, or want to argue about queue semantics at length? Our favorite. hello@unstructred.io.