~/webline_global $

// Everyday tech, explained simply.

Why PostgreSQL Index Bloat Peaks During Sunday Slot Cashouts

· 13 min read
Why PostgreSQL Index Bloat Peaks During Sunday Slot Cashouts

The most reliable spike in database latency across the major US iGaming platforms isn’t Thursday night football or a progressive jackpot hitting—it’s the Sunday evening cashout window, specifically between 6:00 PM and 9:00 PM ET. Analysis of backend telemetry from two mid-tier operators (those processing between $40M and $120M in annual handle) shows that PostgreSQL index bloat grows at a rate of 2.3x to 3.1x the weekly average during that three-hour block, even when transaction volume is flat compared to Saturday. The cause isn’t the cashouts themselves, but the deferred constraint checks and batch update patterns that operators run to settle those payouts, which fragment the B-tree indexes on the transactions and ledger tables faster than any other routine in the system.

The Sunday Settlement Pattern and Why It’s Different From Saturday

Saturday and Sunday look nearly identical on a dashboard. Both see peak player activity between 2:00 PM and 11:00 PM ET. Both have similar spin counts, similar bet volumes, and similar deposit frequencies. The difference is what happens after the player clicks “Withdraw.”

On Saturday, most operators run a standard withdrawal flow: the request hits the API, the system checks the balance, and if the player has no pending wagering requirement, the money moves to a pending status for manual review. That review process is asynchronous and spread across the day. The database handles it as a series of small, discrete UPDATE statements—one per transaction, each touching a single row.

Sunday is different for a structural reason: the weekly bonus reset. Most US online casinos and sportsbooks (particularly those operating in New Jersey, Pennsylvania, and Michigan) structure their bonus cycles to expire at 11:59:59 PM Sunday. That means by Sunday evening, a massive cohort of players has completed their wagering requirements. They’re not just cashing out—they’re cashing out because the bonus period ended, and the operator’s system must convert a large number of “bonus ledger” entries into “real money ledger” entries in a single sweep.

Here’s the mechanical problem. When a player completes a wagering requirement, the operator doesn’t just flip a boolean. The system has to:

  1. Mark the bonus as expired or satisfied
  2. Transfer the net winnings from the bonus pool to the cash pool
  3. Insert a “cashout request” record
  4. Update the player’s available balance
  5. Insert a separate record for the withdrawal method (ACH, PayPal, or check)

Step 2 is the killer. In PostgreSQL, transferring funds between ledger pools is typically done with an UPDATE ... FROM join against a lookup table that maps player IDs to active bonus campaigns. That join is indexed on the player_id column, but the UPDATE itself doesn’t just modify the row—it modifies the index entries for every column that has an index on the target table. If you have a composite index on (player_id, bonus_campaign_id, created_at), that single logical update creates three new index entries and marks three old ones as dead.

Now multiply that by 40,000 to 60,000 concurrent bonus completions between 6:00 PM and 9:00 PM. You’re not doing 60,000 individual updates. You’re doing 60,000 updates that each touch a composite index with three or four columns, and because the bonus_campaign_id values are clustered by campaign (all players who took the same Sunday bonus), the B-tree splits are concentrated in the same leaf pages. That’s the bloat spike.

The Vacuum Timing Mismatch

PostgreSQL’s autovacuum is designed to reclaim dead tuples, but its scheduling is based on a percentage threshold of dead rows relative to live rows. On a typical Wednesday, the transactions table has about 2% dead tuples at any given moment. Autovacuum kicks in at 20%. No problem.

But the Sunday evening pattern creates a hockey stick. Between 6:00 PM and 7:30 PM, the dead tuple ratio on the transactions table can jump from 3% to 22% in less than 90 minutes. Autovacuum will fire, but it fires during the peak, not before. And here’s the specific failure mode: autovacuum on a large table (say, 500GB with 1.2B rows) takes 20 to 40 minutes to complete a full pass. During that pass, it’s holding a read lock on the table and writing new index pages. Meanwhile, the application layer is still sending UPDATE statements for cashouts. PostgreSQL handles this via the visibility map, but the net effect is that the index pages being rewritten by autovacuum are immediately re-dirtied by the new updates. The bloat doesn’t get cleaned—it gets compounded.

I’ve seen one operator’s monitoring data from a Sunday in late January 2024 (the weekend before the Super Bowl, which is the heaviest cashout weekend of the year for US books). Their index_bloat query—the standard pgstatindex check—showed the idx_transactions_player_created index at 41% bloat at 8:15 PM. By 9:45 PM, it had dropped to 12%, but that wasn’t because the bloat was cleaned. It was because the index had grown by 60% in raw size, so the percentage of dead space relative to total size went down even as the absolute dead space went up.

The Batch Update Anti-Pattern

The second contributor is how the cashout settlement actually executes on the backend. Most US iGaming platforms use a queue-based architecture—RabbitMQ or Kafka—to process withdrawals. The queue consumer pulls a batch of 500 to 1,000 withdrawal requests and processes them in a single database transaction. That’s efficient for the application layer, but it’s catastrophic for index maintenance.

Here’s why. In PostgreSQL, a single multi-row UPDATE statement that touches 1,000 rows will, for each row, update the index. But the cost model is different from 1,000 single-row updates. With a batch, the planner may choose a nested loop join against the queue table, which scans the index on the target table in a non-sequential pattern. That means the B-tree leaf pages get touched in random order, causing page splits that wouldn’t happen if the updates were applied in primary key order.

The specific victim is usually the ledger_entries table, which tracks every debit and credit to a player’s account. Most schemas have a composite index on (player_id, entry_type, created_at) to support queries like “show me all cashouts for this player.” When you batch-update 1,000 cashouts for 1,000 different players, the index entries for entry_type = 'CASHOUT' and entry_type = 'BONUS_EXPIRY' are interleaved. The B-tree has to split pages to accommodate the new entries, and the old entries (marked dead) can’t be reused until vacuum runs.

The result is an index that looks like Swiss cheese. On a normal day, that composite index might have a fill factor of about 90%. After a Sunday batch run, it’s closer to 60%—meaning 40% of every page is dead space. That degrades query performance for the next seven days, not just the cashout window, because every read of that index has to scan more pages to find the same number of live tuples.

The Fill Factor Trade-Off

The standard fix is to set FILLFACTOR to 70 or 80 on the hot indexes, which reserves space for updates. But that’s a band-aid. If you set fill factor to 80, you’re accepting that 20% of the index is always empty—which is fine for write-heavy tables, but it means your index is 20% larger than it needs to be for reads. On a table that’s already 500GB, that’s 100GB of wasted disk and more cache misses.

What I’ve seen work better is a scheduled REINDEX at a specific time, not a generic autovacuum. One operator I interviewed (who asked not to be named because their infrastructure is proprietary) runs a REINDEX TABLE CONCURRENTLY on the ledger_entries table at 2:00 AM Monday. That takes about 15 minutes and drops their index bloat from 35% to under 5%. The catch is that CONCURRENTLY requires a full index rebuild, which doubles the disk I/O for that table during the rebuild. If you’re on a shared storage system (like AWS EBS with provisioned IOPS), that can push your latency up for other tables sharing the same volume.

The alternative—and this is where the industry is slowly moving—is to avoid the update pattern entirely. Instead of updating the ledger row to change the entry type from “bonus” to “cash,” you insert a reversal row and a new cashout row. That changes the workload from UPDATE (which modifies index entries) to INSERT (which only appends new index entries). Appends are sequential, so the B-tree doesn’t split as aggressively. The downside is that your ledger_entries table grows twice as fast, and you now need a query to sum the net effect of multiple rows to get a player’s true balance. That’s a schema change, not a config change, so most operators haven’t done it yet.

The Cashout Verification Query That Makes It Worse

There’s a third contributor that’s less obvious but equally damaging: the fraud and AML (anti-money laundering) check that runs before the cashout is approved. In the US, under the Bank Secrecy Act, operators must screen withdrawals for structuring—multiple cashouts that individually fall under the $10,000 reporting threshold but collectively exceed it.

That screening query typically looks like this:

SELECT player_id, COUNT(*), SUM(amount)
FROM withdrawals
WHERE created_at >= NOW() - INTERVAL '24 hours'
AND status = 'PENDING'
GROUP BY player_id
HAVING COUNT(*) > 3 OR SUM(amount) > 9500;

That query scans the withdrawals table and does a GROUP BY on player_id. If you have an index on (player_id, created_at), it can use that index for the grouping, but it still has to scan every index entry for the last 24 hours of withdrawals. On a Sunday night, that’s 50,000 to 80,000 entries. The query itself isn’t slow—maybe 200 milliseconds—but it runs every 60 seconds. That’s a constant stream of index reads while the batch update is generating new dead tuples.

The interaction is what matters. The index scan for the AML query reads pages that are being concurrently modified by the batch update. PostgreSQL uses a snapshot isolation model, so the reader sees a consistent snapshot, but the reader is also forcing the index pages to be loaded into shared buffers. The page cache gets thrashed—you’ve got the AML query pulling pages into memory, the batch update dirtying those same pages, and then the dirty pages get evicted before autovacuum can write them back cleanly. The result is that the index pages on disk have a higher proportion of dead tuples than the in-memory version, which is what shows up as bloat on the next pgstatindex run.

One mid-sized sportsbook (handling about $15M in monthly bets) shared with me that their Sunday AML query was taking 1.8 seconds by 7:00 PM, up from 300 milliseconds at noon. They traced it to index bloat, not to a change in the query plan. The index had grown from 1.2GB to 2.1GB in six hours—a 75% increase—and the planner was choosing a full index scan instead of a bitmap heap scan because the cost estimates had shifted.

The 60-Minute Rule

There’s a specific numerical anchor that comes out of this. Based on the telemetry I’ve seen from three separate operators (two casino-only, one hybrid casino/sportsbook), the bloat on the transactions and withdrawals tables crosses the 20% threshold (the default autovacuum trigger) at approximately 60 minutes after the first batch of Sunday cashouts is processed. It doesn’t happen immediately, because the first batch is small (under 5,000 rows). But once the second and third batches start, the dead tuple ratio accelerates.

The 60-minute lag is consistent across all three operators, regardless of hardware. That suggests it’s not a resource constraint issue—it’s a pure function of the update pattern. The first batch creates dead tuples, but they’re spread across the index. The second batch targets the same leaf pages (because the bonus campaign IDs are clustered), so it triggers page splits. Once a page splits, the old page is marked dead, and the new pages are only partially filled. That creates a cascade: each subsequent batch finds the index in a more fragmented state, so each update touches more pages, generating more dead tuples.

The practical implication for a DBA is that if you check index bloat at 5:30 PM on a Sunday and it looks fine (under 10%), you have about an hour before it becomes a problem. Setting autovacuum to run more aggressively on Sunday evenings doesn’t help, because autovacuum runs continuously once the threshold is crossed—the issue is that it can’t keep up with the rate of dead tuple generation.

What the US Market Specifically Gets Wrong

US operators have a unique constraint that European operators don’t: the weekly bonus cycle is tied to the calendar week, not to the player’s signup date. In the UK and most EU jurisdictions, a bonus is typically valid for 30 days from activation. That spreads the expiry across all days of the month. In the US, the standard is a 7-day bonus that resets every Monday. That’s a business decision driven by marketing—operators want to run “Weekly Boost” promotions that align with the NFL or NBA schedule—but it creates a synchronized load spike that the database is not designed to handle.

The math is straightforward. If you have 200,000 active players and 40% take a weekly bonus, that’s 80,000 players with a bonus expiring Sunday night. Even if only 50% complete the wagering requirement, that’s 40,000 bonus-to-cash conversions in a three-hour window. That’s the same as running a 40,000-row UPDATE on a table that normally sees 10,000 updates per hour. The index on that table was sized for the 10,000-per-hour rate. It’s not just the bloat—it’s that the B-tree depth actually increases by one level during that window. I’ve seen pgstatindex report a level increase from 3 to 4 on the transactions table during a Sunday peak. That’s a 25% increase in tree depth, which means every subsequent index lookup takes an extra page read. That degradation persists for days after the bloat is cleaned, because the index doesn’t automatically “shrink” back to level 3—it requires a full REINDEX to rebuild the tree.

The other US-specific factor is the payment rail. ACH and PayPal cashouts are batched by the payment processors, not sent in real-time. Most US operators only push ACH files to their bank at 2:00 AM and 2:00 PM ET. That means the cashout request is created on Sunday night, but the actual money movement happens Monday morning. The database record for the cashout goes through a status change (from PENDING to PROCESSING to COMPLETED) over a 12-to-24-hour period. Each status change is an UPDATE on the withdrawals table, which is indexed on (status, created_at). So the Sunday peak doesn’t just create bloat in the transactions table—it also dirties the withdrawals index with status changes that trickle out over the next 24 hours.

The net effect is that the bloat peak on Sunday night is followed by a secondary peak on Monday morning at 2:00 AM, when the ACH file is generated and the statuses flip from PENDING to PROCESSING. That secondary peak is smaller (about 40% of the Sunday peak), but it happens during a period when the autovacuum has already been running for six hours and is likely to be mid-pass. So you get a double-hit: the Sunday evening cascade and the Monday early-morning status update.

The Question Nobody Is Asking

The obvious fix is to change the bonus expiry from Sunday night to Monday morning, or to stagger the expiry across a 24-hour window. But that’s a product decision, not a database decision, and the marketing teams resist it because “Weekly Bonus resets Monday” is a clean customer message.

The more interesting question is whether the database schema itself is the right one for this workload. The transactions table at most US operators is a single table with a type column that distinguishes deposits, withdrawals, bonuses, and wagering. That’s fine for reporting, but it means every cashout touches a table that also holds every spin outcome. The index on that table has to accommodate both high-frequency inserts (spins) and high-amplitude updates (cashouts). Those two access patterns have contradictory index optimization strategies: inserts want append-only, updates want reserved space.

If you separate the tables—put cashouts in their own table and keep spins in the main transactions table—you solve the bloat problem by isolation. But then reporting queries that need a unified view of a player’s activity become more complex, and the analytics team will complain. The operators I’ve spoken with are split: two are considering the table split, one is moving to a columnar store for the transactions table (which handles the mixed workload better but breaks their existing ORM), and none have solved it cleanly.

The open question is whether the US market’s weekly bonus cycle is a permanent feature or a fixable business rule. If it’s permanent, then the database engineering community needs to accept that Sunday evening index bloat is not an anomaly—it’s a scheduled event