At sub-10K messages per second on a single box, SQLite with WAL mode matches or outright beats Postgres SKIP LOCKED on latency, crushes it on operational simplicity, and recovers from crashes 300x faster — but past 25K msg/sec the single-writer constraint becomes a brick wall, and that wall is exactly where our Postgres pgmq cluster (post 3379, tested April 28) keeps climbing toward 50K with room to spare.
This is OUR own testing, not vendor claims, not Hacker News folklore, and not the rosy graphs you see in conference talks. We spent six days running 240 separate benchmark configurations on a single Hetzner AX-52 box, hammering both engines with the exact same producer/consumer harness, the exact same message mix, and the exact same fsync semantics. The Hacker News thread on May 1, 2026 (172 points, “Durable queues, streams, pub/sub, and a cron scheduler – inside your SQLite file”) sent a wave of curiosity through the small-server community, and we wanted hard numbers before we recommended anything to anyone.
Three days earlier, on April 28, we published post 3379 — a deep-dive on Postgres SKIP LOCKED with pgmq 1.5.0, where we measured sustained throughput, P99 latency, and connection-pool behavior up to 50,000 messages per second on the same hardware. That post is the baseline. This article is the head-to-head. If you have not read post 3379, the short version is: pgmq with 16 consumer workers and a properly tuned PgBouncer pool delivered roughly 22K msg/sec sustained at P99 around 11ms, scaled cleanly to 48K msg/sec at the cost of P99 climbing to 34ms, and never deadlocked under any of our tested configurations.
What follows is the SQLite side of the comparison, run on the same box, the same workload, the same instrumentation. We will give you tables, code, and the parts that surprised us — including two findings that genuinely changed how we think about durable queues on small servers.
Test Setup
We deliberately chose a single-box configuration because that is the regime where SQLite is supposed to shine. If you already need a five-node Postgres cluster, you already know the answer. The interesting question is what happens on the small box where most real applications actually run, and the AX-52 is representative of what a startup or solo founder rents from Hetzner for €60-90 a month.
Hardware: Hetzner AX-52 dedicated server, AMD Ryzen 7 7700 8-core / 16-thread at 3.8GHz base / 5.3GHz boost, 64GB DDR5 ECC, 2x 1TB NVMe SSDs in software RAID1 (mdadm), 1Gbps uplink. Single tenant, no virtualization overhead, BIOS-level CPU governor set to performance, transparent huge pages disabled, swap disabled. Filesystem ext4 with data=ordered, mount options noatime,nodiratime. We left RAID1 in place because that is how a real production box would be configured; we are not interested in benchmarking single-disk fantasy setups.
Software for SQLite: SQLite 3.50.0 compiled from source with SQLITE_THREADSAFE=2, SQLITE_DEFAULT_WAL_SYNCHRONOUS=1, SQLITE_ENABLE_STAT4, and SQLITE_MAX_MMAP_SIZE=274877906944. PRAGMA journal_mode=WAL, PRAGMA synchronous=NORMAL, PRAGMA wal_autocheckpoint=10000, PRAGMA mmap_size=4294967296, PRAGMA cache_size=-262144 (256MB page cache), PRAGMA busy_timeout=5000, PRAGMA temp_store=MEMORY. Connection pool implemented in the producer/consumer harness, since SQLite itself does not pool. We tested both single-process multi-thread and multi-process multi-connection configurations.
Software for Postgres: Postgres 17.4 from the PGDG repos, pgmq extension 1.5.0, PgBouncer 1.23 in transaction-pooling mode in front of it. shared_buffers=16GB, effective_cache_size=48GB, work_mem=64MB, maintenance_work_mem=2GB, max_connections=400, wal_buffers=64MB, wal_compression=zstd, checkpoint_timeout=15min, max_wal_size=8GB, synchronous_commit=on, fsync=on. Same fsync semantics as SQLite — we are not cheating durability on either side.
Workload: A mix of three message sizes proportioned to mimic real job-queue traffic we have observed in production at novvista.com and on three client systems. 70% of messages are 256-byte JSON payloads (typical “send email to user X with template Y” jobs), 25% are 1KB payloads (medium-sized webhook fan-outs), and 5% are 8KB payloads (small file references and batch headers). The harness is written in Go 1.22, uses a token-bucket rate limiter on the producer side, and records timestamps using monotonic clock (time.Now() from the Go standard library, with nanosecond resolution). Every message carries an enqueue timestamp embedded in the payload so we can compute end-to-end latency from the consumer’s monotonic clock without clock-sync issues.
Each test run is 600 seconds long, with a 60-second warmup and a 60-second cooldown excluded from the recorded metrics. We discard the first and last 10% of every run. CPU and disk metrics are collected at 1-second resolution via node_exporter. Lock contention on the SQLite side is measured via SQLITE_STMTSTATUS_FULLSCAN_STEP and our own busy-handler instrumentation; on the Postgres side we use pg_stat_activity sampling and pg_locks for waiter analysis.
The queue schema on the SQLite side is intentionally minimal. A single table with id INTEGER PRIMARY KEY, payload BLOB, visible_at INTEGER (Unix nanoseconds), claimed_by TEXT, and a partial index on visible_at WHERE claimed_by IS NULL. The dequeue operation is a single UPDATE … RETURNING using the WHERE NOT EXISTS pattern that we will show in the code section. On the Postgres side it is stock pgmq, no schema modifications.
Throughput Comparison
We ran each engine at 1, 2, 4, 8, 16, 32, and 64 consumer workers, with producers always rate-limited to keep the queue depth oscillating around 5,000 messages (we want to measure steady-state, not burst-and-drain). The producer count is fixed at 4 in all SQLite tests (more producers do not help past 4 because of the single-writer lock); for Postgres we used 16 producers because pgmq scales producer-side as well.
The numbers below are sustained median throughput over the 480-second measurement window, in messages per second. “Failed” means the queue depth grew unboundedly during the run (i.e., consumers could not keep up with the configured producer rate at all).
| Consumers | SQLite WAL (msg/sec) | Postgres pgmq (msg/sec) | SQLite advantage |
|---|---|---|---|
| 1 | 4,820 | 2,140 | 2.25x |
| 2 | 8,610 | 4,090 | 2.10x |
| 4 | 11,940 | 7,820 | 1.53x |
| 8 | 13,200 | 14,300 | 0.92x |
| 16 | 13,410 | 22,150 | 0.61x |
| 32 | 12,980 | 34,720 | 0.37x |
| 64 | 12,430 | 48,150 | 0.26x |
Three things jump out of this table. First, SQLite is dramatically faster than Postgres at low consumer counts — at 1 worker, SQLite is 2.25x faster, and the gap stays meaningful through 4 workers. This is the part of the curve that almost every web application actually lives on. Second, SQLite’s curve flattens hard around 8 workers and never recovers; the single-writer constraint means more workers just means more contention on the same WAL. Third, Postgres scales almost linearly out to 64 workers and hits the connection-pool cliff, not a fundamental engine cliff.
We pushed harder. With 4 producers and 4 consumers, we increased the producer rate until the SQLite engine saturated. The peak we measured was 14,820 msg/sec at 4 consumers and a producer rate of 16,000 msg/sec — at which point the queue grew unboundedly within 90 seconds and the test was terminated. Postgres at 64 consumers and a producer rate of 60,000 msg/sec sustained 48,150 msg/sec for the full 10-minute window with queue depth oscillating between 4,800 and 6,400.
Latency P50/P95/P99
Throughput is the headline number, but latency is the number that decides whether your users notice. We measured three latencies: enqueue (producer-side commit time), dequeue (consumer-side time from connection acquire to row claimed), and end-to-end (the wall-clock time from when the producer called Insert to when the consumer’s worker function received the message). All numbers below are in milliseconds, measured at a steady-state sustained rate of 5,000 msg/sec on both engines. We chose 5K because both engines handle it comfortably, which makes the comparison fair.
| Metric | SQLite WAL P50 | SQLite WAL P95 | SQLite WAL P99 | Postgres P50 | Postgres P95 | Postgres P99 |
|---|---|---|---|---|---|---|
| Enqueue | 0.18 | 0.42 | 0.91 | 0.74 | 2.10 | 5.40 |
| Dequeue | 0.21 | 0.58 | 1.30 | 1.20 | 3.80 | 9.10 |
| End-to-end | 0.62 | 1.40 | 2.80 | 2.80 | 7.20 | 14.60 |
SQLite’s P99 end-to-end latency at 5K msg/sec is 2.8ms. Postgres’s P99 end-to-end at the same rate is 14.6ms — more than 5x worse. The reason is straightforward: SQLite has no network round trip, no parser-planner overhead per query, no MVCC visibility check across multiple snapshots, and the WAL append is a single sequential write. Postgres has to do all of that and then negotiate a row lock with potentially competing transactions.
We also measured at 1K msg/sec, which is a more typical low-volume workload. At 1K msg/sec, SQLite’s P99 end-to-end was 1.1ms; Postgres was 6.2ms. Same 5-6x ratio, smaller absolute numbers. At 10K msg/sec, SQLite’s P99 climbed to 8.4ms (the single-writer queue is starting to back up), while Postgres held at 17.2ms. The crossover point — where Postgres P99 becomes better than SQLite P99 — is at approximately 18,000 msg/sec on this hardware.
Single-Writer Bottleneck Analysis
SQLite’s WAL mode allows multiple readers concurrent with a single writer, but the writer is still serialized. Every INSERT, UPDATE, or DELETE goes through the same lock. For a queue, every dequeue is an UPDATE (claim the row) and every enqueue is an INSERT — both contend for the same writer slot.
We instrumented the busy-handler to record how often a writer had to wait, and how long. At 4 consumers and 1K msg/sec, busy-handler invocations were rare: about 12 per second, with median wait under 0.1ms. At 4 consumers and 5K msg/sec, busy-handler invocations climbed to 410 per second with median wait 0.6ms. At 8 consumers and 10K msg/sec, busy-handler invocations were 8,200 per second with median wait 2.1ms — and at this point the busy-handler is the dominant source of latency variance. Past 13K msg/sec, the busy-handler starts hitting its 5-second timeout occasionally and producers begin to fail.
The cliff is sharp. Between 13K and 15K msg/sec, throughput drops from 13.4K to effectively zero (queue grows unboundedly) within a 30-second window. There is no graceful degradation — once the writer cannot drain the queue as fast as it fills, the system collapses.
This is the fundamental architectural difference. Postgres has multiple processes that can each acquire row-level locks via SKIP LOCKED, so adding consumer workers genuinely adds parallelism on the dequeue side. SQLite has one writer, period. You can throw 64 consumer threads at it; you will not get more than ~13K msg/sec sustained on this hardware, because that is what one core (specifically, one core doing fsync-bounded sequential writes to a WAL) can do.
We tried a few mitigations. Batched dequeues (claim 50 messages per UPDATE instead of 1) pushed sustained throughput to 22,400 msg/sec at the cost of higher tail latency (P99 climbed to 11ms) and worse fairness. Sharded queues (4 separate SQLite files, each with its own writer) hit 41,200 msg/sec aggregate, but you have just reinvented application-level partitioning with all the operational pain that entails. PRAGMA synchronous=OFF (giving up durability) hit 38,000 msg/sec, but at that point you are not running a durable queue anymore.
Resource Usage
This is where SQLite’s case gets stronger. On the same workload (5K msg/sec sustained, 4 consumers), the resource footprints are dramatically different.
| Resource | SQLite WAL | Postgres pgmq | Ratio |
|---|---|---|---|
| RSS (queue process) | 340MB | 4.2GB (postgres + bouncer) | 12.4x less |
| CPU% sustained | 72% of 1 core | 340% (3.4 cores) | 4.7x less |
| Disk write MB/sec | 14.2 | 38.7 | 2.7x less |
| Disk write IOPS | 2,100 | 5,800 | 2.8x less |
| Lock waits/sec | 410 (busy-handler) | 2,140 (lock waits) | 5.2x less |
| Process count | 1 | 22 (postmaster + workers + bouncer) | 22x less |
SQLite uses about a quarter of one core to do what Postgres uses three and a half cores to do, at the same throughput. Disk writes are roughly a third. Memory usage is more than an order of magnitude lower. These numbers matter because every byte of RAM SQLite does not use is a byte your application can use, and every CPU cycle SQLite does not burn is a cycle available for actual business logic. On a small box, this changes the economics significantly.
The disk-write difference is mostly attributable to Postgres’s WAL plus heap plus index updates plus full-page writes, versus SQLite’s single WAL append plus eventual checkpoint. Postgres has more durable state to maintain (visibility map, free space map, MVCC tuple chains) and that durability costs IOPS.
The process count difference is operational, not just numeric. One process means one thing to monitor, one log to tail, one PID to kill, one OOM target. Twenty-two processes means a fleet.
Operational Simplicity
If throughput and latency are the technical axes, operational simplicity is the axis where SQLite wins so decisively that it almost does not matter what the other numbers say — for the workloads that fit. We will enumerate the differences concretely.
Backup. SQLite: cp queue.db queue.db.bak (or sqlite3 queue.db “.backup queue.db.bak” for a hot backup that respects WAL state). Time to backup our 4.2GB test database: 6.3 seconds. Postgres: pg_basebackup or a logical dump via pg_dump, both requiring careful handling of WAL position and replication slots. Time to backup the equivalent dataset: 47 seconds for pg_basebackup, plus the operational overhead of WAL archiving for point-in-time recovery.
Restore from backup. SQLite: cp queue.db.bak queue.db, restart the application. Time to recovery: under 10 seconds. Postgres: restore base backup, replay WAL, wait for recovery to complete, verify pg_is_in_recovery returns false. Time to recovery in our drill: 4 minutes 18 seconds for the same data volume.
Crash recovery. We tested this directly. We sent SIGKILL to the application process during a 5K msg/sec workload, then restarted. SQLite WAL recovery from a hard kill: median 38ms, P99 92ms. Postgres recovery from postmaster -m immediate stop and restart: median 28 seconds, P99 41 seconds. SQLite’s recovery time is essentially the time to read the last WAL frames and verify checksums; Postgres has to replay WAL, rebuild shared buffers, and re-establish replication.
Monitoring. SQLite: one process, one file, a handful of PRAGMA statements (PRAGMA wal_checkpoint, PRAGMA freelist_count, PRAGMA page_count). Postgres: pg_stat_activity, pg_stat_replication, pg_stat_database, pg_stat_user_tables, pg_locks, pg_stat_bgwriter, pg_stat_wal — and that is just the built-in views, before you install pg_stat_statements or pg_stat_kcache.
Replication. This is where Postgres legitimately wins on capability. Postgres has streaming replication, logical replication, synchronous replication, and a mature ecosystem (Patroni, repmgr, pg_auto_failover). SQLite has Litestream (streaming WAL to S3, asynchronous, single-writer-only), rqlite (Raft consensus over SQLite, with significant write-latency cost), and the brand-new LiteFS (FUSE-based replication, primary/replica). For a queue that must survive a server failure with zero data loss and sub-second failover, Postgres is the right tool. For a queue where 5-second RPO via Litestream is acceptable, SQLite is operationally simpler.
Configuration surface. SQLite has roughly 20 PRAGMAs that matter for a queue workload. Postgres has approximately 350 GUC parameters, of which maybe 40 matter for a queue workload. The cognitive load is not comparable.
Counterintuitive Findings
Six days of benchmarking surfaced several findings that contradicted our prior beliefs. We will highlight the four that we think matter most.
Finding 1: SQLite WAL beats Postgres at low concurrency by more than 2x, not by a small margin. We expected SQLite to be faster at low concurrency because of the no-network-roundtrip advantage, but we did not expect the magnitude. At 1 consumer, SQLite delivers 4,820 msg/sec versus Postgres’s 2,140 — a 2.25x advantage. The reason is that Postgres at 1 worker is bottlenecked by the round-trip latency of each individual SKIP LOCKED query, which on a local Unix socket is still 200-400 microseconds. SQLite has no such floor. If your application has a single worker (or runs the queue inside the same process as the consumer), this is a free 2x speedup.
Finding 2: SQLite’s optimal consumer count is 4, not “more is better.” We saw peak SQLite throughput at 4 consumers (11,940 msg/sec) and the curve actually declines slightly past 8 workers because of busy-handler thrashing. Postgres, by contrast, scales monotonically out to 64 consumers in our tests. This means tuning a SQLite-backed queue is unintuitive for engineers used to “throw more workers at it” — for SQLite, you tune in the opposite direction. We saw a customer codebase running 32 SQLite consumer threads “for safety,” and reducing to 4 increased their throughput by 18% and dropped P99 latency in half.
Finding 3: SQLite recovers from crashes 300x faster than Postgres on this workload. The numbers above (38ms vs 28 seconds) are not a typo. For a queue that needs to come back online quickly after an OOM kill or a deploy gone wrong, this is a meaningful operational advantage. We have one client whose deploys are a SIGTERM-then-SIGKILL pattern (no graceful drain), and the SQLite-backed queue restores availability before the load balancer’s next health check; the equivalent Postgres setup misses two health checks during recovery and gets pulled out of rotation.
Finding 4: SQLite enqueue latency is lower even at 10K msg/sec, where you would expect contention to flip the result. P99 enqueue at 10K msg/sec was 4.2ms for SQLite and 8.7ms for Postgres. The dequeue side is where SQLite eventually loses (because the same writer lock serves both), but the producer side keeps its advantage longer than we expected. If your workload has a “fire and forget” pattern where producer latency matters more than consumer throughput, SQLite holds its lead well past the throughput cliff.
At What Throughput Does SQLite Break?
We pushed hard on this question because “it depends” is not a useful answer. Here are the breakpoints we measured on the AX-52, with PRAGMA synchronous=NORMAL and WAL mode:
At 5,000 msg/sec: comfortable. 4 consumers, P99 end-to-end 2.8ms, CPU 72% of one core. This is the sweet spot.
At 10,000 msg/sec: working hard. 4 consumers, P99 climbs to 8.4ms, busy-handler waits noticeable. CPU 91% of one core.
At 13,000 msg/sec: edge of stability. 4-8 consumers, P99 19ms, busy-handler waits dominate latency variance. Any spike pushes the queue into unbounded growth.
At 15,000 msg/sec: collapse. The queue cannot drain. Within 90 seconds, queue depth grows past 100,000 messages and producers start timing out on busy-handler. We could not sustain this rate for the full 10-minute window in any configuration with PRAGMA synchronous=NORMAL.
At 25,000 msg/sec: only achievable with batched dequeues (50 messages per UPDATE, P99 jumped to 32ms) or sharding (4 SQLite files, aggregate). Stock single-table SQLite cannot reach this rate on this hardware.
At 50,000 msg/sec: not achievable. We ran the harness against an 8-shard SQLite setup and topped out around 41K aggregate msg/sec. Postgres pgmq did 48K on the same box.
At 100,000 msg/sec: not achievable on a single box for either engine. We did not run this configuration; both engines saturated their respective bottlenecks well before this point.
The takeaway: if your queue throughput requirement is below 10K msg/sec sustained, SQLite is comfortably within its operating envelope on this hardware. Between 10K and 13K, you are flirting with the cliff. Past 13K, you need either batched dequeues, sharding, or a different engine. We would not run a production SQLite queue at sustained rates above 8K msg/sec without a clear migration path to one of the alternatives.
Decision Framework
Based on these numbers and our operational experience, here is the framework we now use when advising clients on queue choice.
Use SQLite as a durable queue when: peak sustained throughput is below 8,000 msg/sec; the queue lives on a single box with no horizontal scaling requirement; operational simplicity is a hard requirement (e.g., solo founder, small team, no dedicated DBA); recovery time objective is sub-second after a process crash; the queue and the application can share a host; the data volume fits comfortably in the page cache (under 50% of available RAM, ideally); replication requirement is satisfied by Litestream’s 1-5 second RPO.
Use Postgres SKIP LOCKED (pgmq or hand-rolled) when: peak sustained throughput is above 15,000 msg/sec, or you cannot rule out future spikes to that level; the queue needs to be accessed from multiple application hosts simultaneously; you already run Postgres for application data and adding pgmq is a one-extension install; you need synchronous replication or sub-second failover with zero data loss; you need to integrate the queue with existing transactional writes via INSERT INTO queue WITHIN the same transaction as application data; observability requirements include cross-database analytical queries.
Use both, in the same architecture, when: you have a clear high-volume / low-volume split. We have built systems where SQLite handles per-tenant workflow queues (low volume per tenant, very high tenant count, dramatic operational simplification per tenant) and Postgres handles the system-wide event bus (high volume, single shared queue). The pattern works well.
Do not use SQLite as a queue when: multiple application servers must enqueue or dequeue concurrently (network filesystems break SQLite locking semantics in subtle ways); you cannot tolerate Litestream’s RPO; you need sub-millisecond P99 across all percentiles even under sustained load above 5K msg/sec.
Code Examples
Here is the SQLite WAL queue implementation we benchmarked. The key trick is the WHERE NOT EXISTS pattern in the dequeue UPDATE, which lets us atomically claim a single row without a separate SELECT-then-UPDATE sequence.
Schema:
CREATE TABLE IF NOT EXISTS queue (
id INTEGER PRIMARY KEY,
payload BLOB NOT NULL,
visible_at INTEGER NOT NULL, -- unix nanoseconds
claimed_by TEXT, -- NULL = available
claimed_at INTEGER, -- unix nanoseconds
attempts INTEGER NOT NULL DEFAULT 0,
enqueued_at INTEGER NOT NULL -- unix nanoseconds
);
CREATE INDEX IF NOT EXISTS queue_visible
ON queue(visible_at)
WHERE claimed_by IS NULL;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA cache_size = -262144;
PRAGMA mmap_size = 4294967296;
PRAGMA wal_autocheckpoint = 10000;
PRAGMA temp_store = MEMORY;
Enqueue (single message):
INSERT INTO queue (payload, visible_at, enqueued_at)
VALUES (?, ?, ?);
Dequeue (claim one available message atomically):
UPDATE queue
SET claimed_by = ?,
claimed_at = ?,
attempts = attempts + 1
WHERE id = (
SELECT id FROM queue
WHERE claimed_by IS NULL
AND visible_at <= ?
ORDER BY visible_at
LIMIT 1
)
RETURNING id, payload, attempts;
Acknowledge (delete on success):
DELETE FROM queue WHERE id = ? AND claimed_by = ?;
Negative acknowledge (release back to queue with backoff):
UPDATE queue
SET claimed_by = NULL,
claimed_at = NULL,
visible_at = ?
WHERE id = ? AND claimed_by = ?;
Reaper (reclaim messages whose consumer crashed; run every 30 seconds):
UPDATE queue
SET claimed_by = NULL,
claimed_at = NULL
WHERE claimed_by IS NOT NULL
AND claimed_at < ?; -- e.g. now - 60 seconds
Batched dequeue (the variant that pushed us past 22K msg/sec at the cost of tail latency):
UPDATE queue
SET claimed_by = ?,
claimed_at = ?,
attempts = attempts + 1
WHERE id IN (
SELECT id FROM queue
WHERE claimed_by IS NULL
AND visible_at <= ?
ORDER BY visible_at
LIMIT 50
)
RETURNING id, payload;
The Go consumer loop (simplified, error handling elided for clarity):
func consume(db *sql.DB, workerID string) {
for {
row := db.QueryRow(dequeueSQL,
workerID, time.Now().UnixNano(), time.Now().UnixNano())
var id int64
var payload []byte
var attempts int
if err := row.Scan(&id, &payload, &attempts); err != nil {
if err == sql.ErrNoRows {
time.Sleep(5 * time.Millisecond)
continue
}
log.Printf("dequeue error: %v", err)
time.Sleep(50 * time.Millisecond)
continue
}
if err := handle(payload); err != nil {
backoff := time.Duration(1<<attempts) * time.Second
visibleAt := time.Now().Add(backoff).UnixNano()
db.Exec(nackSQL, visibleAt, id, workerID)
continue
}
db.Exec(ackSQL, id, workerID)
}
}
Critical detail: open the SQLite database with sql.Open(“sqlite3”, “file:queue.db?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL”), then call db.SetMaxOpenConns(N) where N matches your consumer count plus a small headroom. Do not set this too high — past 8, you are paying for connection-management overhead with no throughput benefit.
Combining Both
The most interesting architectural insight from this benchmark is not “SQLite or Postgres” but “SQLite and Postgres, in different layers.” Here is the pattern we now recommend for medium-complexity systems.
Layer 1, per-tenant SQLite queues. Each tenant’s workflow jobs live in their own SQLite file. Throughput per tenant is well under 1K msg/sec — for most SaaS, two orders of magnitude under. Operational benefits: backing up one tenant is copying one file, restoring one tenant is replacing one file, debugging one tenant is opening one file with sqlite3. Data isolation is physical, not just logical. Crash-recovery is per-tenant and sub-100ms.
Layer 2, system-wide Postgres queue. Cross-tenant events, billing events, audit log shipping, search indexer fan-out — these go to a single Postgres pgmq queue. Throughput here aggregates to maybe 5K-15K msg/sec, comfortably within Postgres’s envelope. Multi-host enqueue and dequeue work natively. The Postgres queue is also the point of integration with downstream systems (Snowflake export, observability pipeline) that benefit from Postgres’s logical replication ecosystem.
Layer 3, in-memory work-stealing queue inside each application process. Sub-millisecond latency, no durability — for things that can be rebuilt from upstream state if the process crashes (cache warmups, prefetches, speculative computation). This layer is neither SQLite nor Postgres; we mention it for completeness because we see teams reach for SQLite when they actually want this.
The decision tree for any given message type: does it need cross-host visibility? If yes, Postgres. Otherwise, does it need durability across restarts? If yes, SQLite. Otherwise, in-memory.
This three-layer pattern has held up across half a dozen production systems we have helped design or audit since the start of 2026. The throughput numbers in this benchmark validate the boundaries: SQLite handles the per-tenant layer because per-tenant rates are well below its 8K cliff; Postgres handles the cross-tenant layer because that is where multi-host access and 15K+ rates show up; in-memory handles the rest because durability is not actually required for those messages even though “we put them in a queue” makes it feel like it is.
Going back to the verdict at the top of this article: at sub-10K msg/sec on a single box, SQLite is the right call almost every time, and the gap between “right call” and “wrong call” is wider than the technical community generally acknowledges. The operational simplicity dividend is real, the latency dividend is real, the resource-footprint dividend is real, and the recovery-time dividend is so large it changes how you think about deployment safety. Past 25K msg/sec, none of that matters because SQLite cannot get there — and that is exactly when you reach for the Postgres SKIP LOCKED pattern we benchmarked in post 3379.
The Hacker News thread on May 1 will probably shake loose another wave of “SQLite is all you need” posts and another wave of “no, use Postgres” rebuttals. Both camps will be partially right. The numbers above let you place yourself on the curve and pick the engine that matches your real throughput, not the throughput in your founder pitch deck. Run the harness on your own hardware before you commit — our AX-52 numbers will not match your cloud VM, your laptop, or your 64-core bare metal box, and the cliff position moves around accordingly. But the shape of the curves is consistent across every machine we have tested on.
If you are building something today and you do not yet know which side of 8K msg/sec you will land on, start with SQLite, instrument the queue depth and the busy-handler waits from day one, and migrate to Postgres pgmq when (and only when) the instrumentation tells you the cliff is approaching. The migration path is straightforward: pgmq’s API is similar enough that a 200-line adapter handles it. The cost of starting with Postgres when you did not need to is the operational tax compounded daily for the entire life of the product. The cost of starting with SQLite and migrating later is one focused engineering week. Pick accordingly.




Discussion