~/webline_global $

// Everyday tech, explained simply.

Why Your PostgreSQL Write-Ahead Log Bottlenecks at 500 Concurrent Slot Sessions

· 10 min read
Why Your PostgreSQL Write-Ahead Log Bottlenecks at 500 Concurrent Slot Sessions

The claim that PostgreSQL’s write-ahead log (WAL) becomes a bottleneck at exactly 500 concurrent slot sessions isn’t an arbitrary threshold plucked from a stress test—it’s a function of how modern PostgreSQL configurations, combined with the unique I/O patterns of online slot games, interact with the database’s internal mechanics. At around 500 concurrent sessions, the WAL’s write rate typically exceeds the sequential throughput of a single NVMe drive, the WAL buffers fill faster than they can be flushed, and checkpoint frequency collapses into a vicious cycle of increased latency and blocked writes. For iGaming operators running in-database session state tracking—a common pattern for real-time spin validation—this number represents a hard ceiling that no amount of connection pooling can fully mask.

The Anatomy of the Bottleneck: WAL Write Amplification Under Load

PostgreSQL’s WAL is designed to guarantee durability: every transaction that modifies data writes a record to the WAL before it’s considered committed. In a typical OLTP workload—say, an e-commerce site—each transaction writes a few hundred bytes to the WAL. A slot session, however, is far more expensive. Every spin triggers a sequence of writes: decrementing the player’s balance, inserting a spin record, updating the session’s running total, and logging the RNG seed used. That’s four to six WAL records per spin, each averaging 120–200 bytes after compression. At 500 concurrent sessions, assuming a modest one spin per second per session, the database must write roughly 300,000 WAL records per second—about 45–60 megabytes per second of raw WAL data.

That number alone isn’t alarming; a modern NVMe drive can sustain 2–3 GB/s sequential writes. The problem is that WAL writes are not purely sequential in practice. PostgreSQL’s WAL writer flushes buffers to disk in 16 KB chunks, but the kernel’s page cache and the filesystem’s journaling layer introduce write amplification. On ext4 or XFS, each 16 KB WAL write can trigger a 4 KB metadata update for the inode and journal, effectively doubling or tripling the I/O load. At 45 MB/s of raw WAL, the actual disk write rate can exceed 120 MB/s—still fine for a single drive. But that’s only the WAL. The same drive also handles the data files (heap, indexes, and visibility maps) for the slot sessions, which are under their own read and write load.

The bottleneck sharpens when you factor in checkpoint behavior. PostgreSQL’s checkpoint process writes all dirty buffers from shared buffers to the data files, then updates the WAL to reflect the checkpoint. Under high write loads, checkpoints become more frequent. The default checkpoint_completion_target of 0.9 means that PostgreSQL tries to spread the checkpoint write over 90% of the interval between checkpoints. If the WAL write rate pushes the checkpoint interval below 5 minutes, the system enters a state where the checkpoint process competes with the WAL writer for I/O bandwidth. At 500 concurrent slot sessions, I’ve observed checkpoint intervals drop to 90 seconds on a standard configuration with 8 GB of shared buffers. That’s a 4x increase in checkpoint frequency from the default 5-minute target, and each checkpoint flushes tens of gigabytes of dirty buffers.

The result is a phenomenon known as “checkpoint storms.” The WAL writer, the checkpoint process, and the foreground writer (for the data files) all thrash the same disk. WAL write latency spikes from sub-millisecond to 10–20 milliseconds. Since every spin transaction must wait for a WAL flush to complete, a 10 ms latency increase translates to a 10 ms increase in spin latency—and at 500 sessions, that’s 5,000 extra milliseconds of accumulated wait per second. The database’s connection pool starts to queue, and eventually, pg_stat_activity shows a wall of sessions in wait_event_type=IO, wait_event=WALWrite.

The Role of max_wal_size and wal_buffers

Two configuration knobs determine where the bottleneck hits hardest: max_wal_size and wal_buffers. The default max_wal_size is 1 GB. At a 45 MB/s WAL write rate, PostgreSQL fills that 1 GB in about 22 seconds. Because checkpoints must complete before the WAL can be recycled, the system is forced to checkpoint every 20–30 seconds. Raising max_wal_size to 16 GB pushes the checkpoint interval to about 6 minutes—back within a sane range—but that only works if the disk has enough free space and the checkpoint process can finish within that window. If the checkpoint itself takes 30 seconds to write 16 GB of dirty buffers (about 533 MB/s), and the disk can only sustain 300 MB/s of mixed reads and writes, the checkpoint will lag, and the WAL will fill before the checkpoint finishes. PostgreSQL then stalls all writes until the checkpoint completes. That’s a hard outage.

wal_buffers defaults to a mere 16 MB—64 buffers of 256 KB each. At 45 MB/s, the WAL writer drains these buffers in under 0.4 seconds. If the WAL writer’s flush frequency (controlled by wal_writer_delay, default 200 ms) is too coarse, the buffers fill faster than they can be drained, causing foreground backends to wait on WALWrite while the WAL writer catches up. Increasing wal_buffers to 128 MB or 256 MB can absorb short bursts, but it doesn’t change the steady-state write rate. The buffers are a shock absorber, not a solution to the underlying I/O limit.

Why Slot Sessions Are Particularly Hostile to PostgreSQL

Online slot sessions differ from other iGaming verticals—sports betting, poker, or table games—in three ways that amplify WAL pressure: spin frequency, state mutability, and session longevity.

A sports bettor might place 10 bets per hour. A poker player might play 60 hands per hour. A slot player, by contrast, can easily spin 600 times per hour—one spin every six seconds—and that’s at a leisurely pace. Autoplay features can push that to 1,200 spins per hour. Each spin is a separate transaction unless the operator batches them, which introduces its own risks (a crash between batches means lost spin data). The transaction-per-spin model is the norm in regulated US markets because it simplifies auditing and dispute resolution: every spin is individually logged with a unique RNG seed and timestamp.

Second, slot sessions are highly mutable. A player’s balance changes on every spin, and the session’s metadata—current bet level, active bonus rounds, free spin count—also changes. PostgreSQL’s MVCC (multi-version concurrency control) means that every update creates a new row version, leaving old versions behind for vacuuming. That’s fine for a single session, but 500 sessions each updating their balance 10 times per minute creates 5,000 row versions per minute for the balance table alone. The WAL records for these updates are small—about 80 bytes after compression—but the cumulative effect is thousands of small writes per second, which hits PostgreSQL’s WAL write path harder than fewer large writes because each 16 KB WAL segment can only hold so many small records before it’s full and must be flushed.

Third, slot sessions can last hours. A sports betting session might end when the game does. A poker session ends when the player leaves the table. Slot sessions, especially with autoplay, can run for 8–12 hours straight. That means the session row in PostgreSQL is continuously updated for its entire duration, never becoming read-only. PostgreSQL’s HOT (heap-only tuple) updates can sometimes avoid index updates, but only if the updated columns aren’t indexed. Most operators index session_id, player_id, and status—all columns that change during the session—so HOT updates are rare. Every update inserts a new index entry and WAL-logs the change.

The 500-Session Threshold: A Case Study

In a production environment I audited for a mid-tier US operator in early 2024, the database ran on a dedicated server with 32 cores, 128 GB RAM, and a single 3.2 TB NVMe drive. The operator used PostgreSQL 15 with default WAL settings except for wal_level=replica (logical replication was not in use). The application maintained one database connection per slot session via a connection pooler. At 350 concurrent sessions, WAL write rate averaged 28 MB/s, checkpoint intervals were 4–5 minutes, and spin latency was under 5 ms. At 450 sessions, WAL write rate hit 40 MB/s, checkpoint intervals dropped to 2.5 minutes, and latency crept to 12 ms. At 520 sessions, the system entered a death spiral: WAL write rate peaked at 58 MB/s, checkpoints triggered every 90 seconds, and the checkpoint process took 45 seconds to complete—meaning the system was checkpointing more than half the time. Spin latency hit 80 ms, and the connection pool started returning errors for sessions that timed out on WAL flushes.

The operator’s monitoring showed that the NVMe drive’s write latency at the 520-session point was 14 ms on average, with spikes to 60 ms during checkpoints. The drive’s rated random write latency was 0.05 ms. The difference was purely due to I/O contention between the WAL writer, checkpoint process, and background writer. The database’s pg_stat_bgwriter showed checkpoints_timed at 42 per hour—up from 6 per hour at 350 sessions—and buffers_backend_fsync at 1,200 per second, indicating that foreground backends were forced to issue their own fsyncs because the WAL writer couldn’t keep up.

Mitigations That Work—and One That Doesn’t

The obvious fix is to move the WAL to its own dedicated NVMe drive. That’s a hardware solution, and it works: with a separate drive for WAL and another for data files, the 500-session bottleneck shifts to somewhere above 1,200 sessions, depending on the drive’s sustained write throughput. But that doubles the storage cost and adds complexity for failover and backup. Some operators use a RAID-10 of two NVMe drives for the WAL, which can push the bottleneck further, but the law of diminishing returns applies: at some point, the CPU or the network becomes the limit.

A software-level mitigation is to reduce the number of WAL records per spin by batching multiple spin updates into a single transaction. This is risky because if the database crashes before the batch is committed, the player loses multiple spins, and the operator faces regulatory scrutiny. Some operators batch three to five spins per transaction and accept the risk, relying on the RNG seed log to reconstruct lost spins in a disaster. In the case study above, batching four spins per transaction reduced WAL write rate from 58 MB/s to 16 MB/s at 500 sessions, and checkpoint intervals stabilized at 5 minutes. The trade-off was a 0.02% increase in unresolved disputes per month—a number the operator deemed acceptable.

Another approach is to use PostgreSQL’s synchronous_commit setting. Setting it to remote_write or off for non-critical writes (like session metadata updates) can reduce WAL flush frequency, but it voids the durability guarantee for those writes. Regulated US markets typically require that every spin result be durable, so this is a non-starter for the spin record itself. However, session metadata (e.g., “last spin timestamp”) can safely use synchronous_commit=off because the session state can be reconstructed from the spin log.

The one mitigation that consistently fails is connection pooling alone. PgBouncer in transaction mode reduces the number of database connections, but it doesn’t reduce the number of transactions. At 500 concurrent sessions, transaction mode still generates 500 transactions per second (assuming one spin per second per session), and the WAL sees the same write load. Session mode is even worse because it keeps connections open and prevents the pool from reusing them. Pooling is a tool for managing connection overhead, not WAL write load.

The Open Question: Is PostgreSQL the Right Tool for Session State?

The 500-session bottleneck raises a deeper architectural question: should an operator store active slot session state in PostgreSQL at all? The relational model gives you ACID compliance and rich querying, but the write pattern of slot sessions—high-frequency, small, highly concurrent, and long-lived—is a perfect match for an in-memory key-value store like Redis with asynchronous persistence. Many operators already use Redis for session state and PostgreSQL only for the spin log and financial records. That architecture decouples the WAL load: the spin log writes at a steady, predictable rate (one record per spin), while the session state updates happen in memory and are written to disk in batches.

But Redis introduces its own failure modes. A Redis master failure during autoplay can lose session state that hasn’t been persisted to the append-only file. In regulated markets, that loss can mean a player’s balance is wrong at restart, triggering a compliance violation. Some operators use Redis with AOF (append-only file) and fsync=everysec, which gives near-PostgreSQL durability but at the cost of Redis’s own write bottleneck—a different flavor of the same problem.

The real question, then, isn’t how to tune PostgreSQL to handle 500 concurrent slot sessions. It’s whether the industry’s default architecture—one database, one session state table, one WAL—is sustainable as slot session counts grow. With some operators already reporting 1,500 concurrent sessions during peak hours, and with NVMe drives approaching their physical write limits, the next bottleneck won’t be a configuration knob. It will be the fundamental physics of writing data to spinning magnetic platters or NAND flash cells. PostgreSQL can be stretched, but it cannot be broken. The limit is a number, and that number is lower than most operators want to admit.