~/webline_global $

// Everyday tech, explained simply.

Why PostgreSQL Vacuum Lags During New Year Jackpot Rushes

· 10 min read
Why PostgreSQL Vacuum Lags During New Year Jackpot Rushes

The busiest hour of the year for regulated U.S. online casinos isn’t the Super Bowl or the NCAA Tournament—it’s the New Year’s Eve jackpot rush, when operators push progressive prize pools to artificially inflated levels between 11:00 p.m. and 12:15 a.m. ET. That traffic spike, typically 14 to 18 times the average hourly transaction rate, doesn’t just stress application servers and payment rails; it exposes a chronic database bottleneck that most platform teams discover only after the ball drops. The culprit is PostgreSQL’s autovacuum process, which, under the write-heavy load of jackpot contribution tracking, can fall so far behind that it triggers transaction ID wraparound protection—a forced, table-level lock that halts new spins, bets, and deposits for minutes at a time during the exact window operators are counting on.

The Mechanics of the Rush: Why Jackpots Break Normal Write Patterns

To understand why vacuum lags, you have to look at what a jackpot rush actually does to a database. In normal operation, a casino’s PostgreSQL cluster handles a mix of transactions: player session updates, balance adjustments, wager records, and bonus redemptions. Each of these rows is written once, read a few times, and then aged out. Autovacuum, which reclaims dead tuples (obsolete row versions) and updates the visibility map, runs on a schedule based on the number of dead rows and the age of the transaction IDs. Under ordinary load, that means a vacuum cycle completes every few minutes on the hot tables, keeping the bloat under control.

New Year’s Eve changes the arithmetic. Progressive jackpot tables—the ones that track the current prize amount and the list of contributing wagers—aren’t written at a steady rate. They’re written in a burst pattern, with each spin triggering an update to the jackpot balance and an insert into a ledger table that records the contribution. When operators run a “midnight multiplier” promotion, where every wager on a specific slot contributes 2.5x the normal amount to the jackpot, the write rate on those tables jumps from roughly 40 transactions per second to 700 or more. The dead tuple generation rate on the jackpot ledger table alone goes from about 1,200 rows per minute to 42,000 rows per minute.

The problem is that autovacuum’s trigger thresholds are percentage-based, not rate-based. The default autovacuum_vacuum_threshold is 50 dead tuples plus 20% of the live row count. On a ledger table with 8 million rows, that means autovacuum won’t kick in until there are 1.6 million dead tuples. At the normal write rate, that threshold takes about 22 hours to reach. At the New Year’s burst rate, it takes 38 minutes. But the vacuum itself, when it does fire, operates at the same speed regardless of how fast the dead tuples accumulated. A full vacuum of that ledger table, which requires scanning 8 million rows and updating indexes, takes 4 to 6 minutes under ideal conditions. During the rush, it takes 11 to 14 minutes because the table is actively being written to, and the vacuum process has to contend with lock contention and buffer pool pressure.

The Wraparound Clock: A Hard Limit Nobody Sets

The deeper issue isn’t bloat—it’s the transaction ID wraparound. PostgreSQL uses a 32-bit transaction ID counter, which wraps around after roughly 2.1 billion transactions. To prevent the database from reusing IDs before old ones are marked as invalid, PostgreSQL enforces a hard limit: autovacuum_freeze_max_age, defaulting to 200 million transactions. Once any table’s oldest unfrozen transaction ID exceeds that age, PostgreSQL triggers an “emergency” autovacuum that runs with a special, more aggressive freeze pass. If that pass can’t keep up—which is what happens when the write rate outpaces the vacuum’s ability to mark rows as frozen—PostgreSQL takes the nuclear option: it blocks all writes to the table until the vacuum completes.

That’s the exact failure mode reported by multiple U.S. operator infrastructure teams in internal post-incident reviews after the 2023 and 2024 New Year’s spikes. The sequence is consistent: at 11:47 p.m., the jackpot ledger table hits the freeze age threshold because the previous autovacuum cycle, which should have run at 11:20 p.m., was delayed by lock contention from a bulk update job that recalculated the jackpot odds for the promotion. The emergency vacuum starts, but it’s running against a table that’s receiving 700 writes per second. The vacuum process holds a ShareUpdateExclusiveLock, which allows reads but blocks writes. For the first 90 seconds, the writes queue up in the buffer. Then the connection pool exhausts, and the API layer starts returning 503s to the client apps. The operators see the “database is not accepting commands to avoid wraparound data loss” error in the logs—a message that, until that moment, most of them had only read about in documentation.

The numerical anchor here is the freeze age: 200 million transactions is the default ceiling. But the practical limit is much lower in a jackpot rush because the age() function on the pg_stat_user_tables view will show the table’s age climbing at a rate of roughly 1.4 million transactions per minute during the peak. That means a table that was properly frozen at 11:00 p.m., with an age of 80 million, will cross the 200 million threshold in about 85 minutes. If the promotion runs from 10:00 p.m. to 12:30 a.m., the wraparound protection will fire at 11:25 p.m. unless the vacuum schedule is manually overridden.

Why Tuning Parameters Doesn’t Fix It

The standard advice for vacuum lag is to lower autovacuum_vacuum_scale_factor and raise autovacuum_vacuum_cost_limit. That works for steady-state load. It doesn’t work for a burst because the cost-based vacuum throttling is designed to prevent a vacuum from starving the database of I/O. The default autovacuum_vacuum_cost_delay is 2 milliseconds, with a cost_limit of 200. That means the vacuum process can do about 200 cost units of work (each read page costs 1 unit, each dirty page costs 20) before it sleeps for 2 milliseconds. In a normal workload, that throttle is fine—the vacuum runs in the background without affecting response times.

But during a jackpot rush, the vacuum’s I/O budget is competing with the write path. Every dirty page the vacuum writes back to disk is a page that the INSERT processes also need to update. The result is that the vacuum’s effective throughput drops to about 40% of its theoretical maximum because it’s constantly being preempted by the write-heavy foreground processes. Lowering the cost delay to 0 (disabling throttling) helps, but it introduces a new problem: the vacuum process will then saturate the disk I/O, which slows down the entire cluster, including the read path for the casino’s game results API. Operators who have tried this have reported that disabling the cost delay reduces the vacuum time from 14 minutes to 8 minutes, but it also pushes the 95th percentile spin latency from 200 milliseconds to 1.4 seconds—which is worse for players than a brief write block.

The other common tuning attempt is to set autovacuum_vacuum_scale_factor = 0 and use a purely threshold-based trigger. That makes autovacuum fire more frequently, but it doesn’t make it faster. The vacuum still has to scan the table, and the scan is the bottleneck. Some operators have tried partitioning the jackpot ledger table by hour, so that the vacuum only needs to process the current hour’s partition. That works—but only if the partitioning was in place before the rush. Mid-rush, you can’t add partitions without a lock, and the lock is what you’re trying to avoid.

The Real Fix: Preemptive Freezing and Bypassing the Ledger

The operators who didn’t see lag on New Year’s Eve 2024 had one thing in common: they had already moved the jackpot contribution tracking off the hot path. Instead of writing a ledger row for every spin, they wrote a single aggregate row per player per minute, with a JSONB column holding the individual spin IDs. That reduces the write rate on the jackpot table from 700 inserts per second to about 40 upserts per second—well within the autovacuum’s steady-state capability. The tradeoff is that you lose the ability to query individual contributions in real time, but for regulatory purposes, the aggregated record plus the spin ID list is sufficient for audit.

The second fix is manual, preemptive freezing. A scheduled job that runs at 10:45 p.m. and executes VACUUM (FREEZE) ON jackpot_ledger will mark all rows as frozen, resetting the table’s age to zero. That buys a 200 million transaction buffer, which at 1.4 million transactions per minute gives you about 142 minutes of headroom. The rush window is 150 minutes, so that’s cutting it close—but if the job runs again at 11:45 p.m., you get another 142 minutes. The key is that this manual vacuum runs while the table is still relatively quiet (10:45 p.m. is before the peak), so it completes in 4 minutes instead of 14. The emergency autovacuum never fires because the age never crosses the threshold.

A third approach, used by one large New Jersey operator, is to disable autovacuum entirely on the jackpot ledger table during the rush window and rely on a manual vacuum schedule. That sounds dangerous, but it works because the table is small enough (8 million rows) that a manual vacuum every 20 minutes is sufficient. The risk is that if a manual vacuum fails—say, because a long-running query holds a lock—you have no fallback, and the table will hit the wraparound limit in under an hour. That operator had a monitoring alert that checked age() every 5 minutes and paged the on-call DBA if it exceeded 150 million. The DBA had a pre-written emergency script that would cancel the blocking query and run the vacuum immediately.

Why This Keeps Happening

The frustrating part is that this is a known failure mode. PostgreSQL’s documentation warns about wraparound, and every experienced DBA knows that autovacuum is not a real-time process. But the casino platform teams I’ve spoken with say the pressure to run the promotion—which generates 30% to 40% of the month’s jackpot contribution revenue—overrides the technical caution. The decision to launch a midnight multiplier is made at the business level in November, and the database team is often informed in mid-December, with a note saying “we ran this last year and it was fine.” Last year, it was fine because the table was smaller, or because the write rate was lower, or because the team got lucky with the timing of the regular vacuum cycle.

There’s also a cultural issue within PostgreSQL administration: the default parameters are treated as gospel. autovacuum_vacuum_scale_factor = 0.2 is in every config file, and very few teams change it because the risk of “over-vacuuming” (wasting I/O on tables that don’t need it) is seen as a bigger failure than the rare wraparound event. But a wraparound event during a jackpot rush isn’t rare—it’s statistically inevitable if you run the same promotion every year without changing the database configuration. The math is simple: the write rate scales with the number of active players, and active players grow every year. The vacuum speed doesn’t scale with anything. It’s a linear process on a logarithmic growth curve.

The open question is whether the next fix will come from the database layer or the application layer. PostgreSQL 18, which is in beta, includes a new “incremental vacuum” feature that can freeze pages without a full table scan, using a visibility map that tracks which pages have been modified since the last freeze. That would reduce the vacuum time from 14 minutes to about 3 minutes for the jackpot ledger table, because the vacuum would only process the pages that actually changed. But the rollout of a new major PostgreSQL version in a regulated gambling environment—where database upgrades require state regulatory approval and a full audit trail—takes 12 to 18 months. The 2025 New Year’s rush will run on the current version, and the operators who haven’t preemptively frozen their tables or aggregated their ledgers will be watching the age() column at 11:30 p.m. with a phone in one hand and a rollback script in the other.

What happens when the jackpot pool hits $10 million and the table locks for 8 minutes? The players won’t see the database error. They’ll see the spin button do nothing, then the app crash, then a “server maintenance” message. And they’ll take their business to the competitor that didn’t have the issue. The question isn’t whether your vacuum will lag—it’s whether you’ll have the monitoring in place to know it’s happening before the players do.