A sportsbook whose Postgres slows down during big matches and settles bets long after the event ends is running two workloads through one set of rows: placement, a short write per request, and settlement, a burst triggered by one result. This is how the two paths are separated, where the contention comes from, and how balances stay correct while settlement runs late.
A sportsbook whose Postgres becomes the bottleneck during a big match usually has one symptom and two causes. Placement and settlement compete for the same rows, locks and connections exactly when traffic peaks, and settlement runs as work that holds those rows instead of as a queue that can wait its turn.
More hardware raises the traffic level at which this happens without removing the cause. What follows separates the two paths, locates the contention, and keeps balances correct while settlement runs late. PostgreSQL behaviour quoted below is from the version 18 documentation.
The short answer is structural. Placement and settlement stop sharing transactions: placement writes the bet, a balance reservation and an outbox row in one short transaction under an idempotency key, and settlement consumes result events in small batches whose effects are keyed too, so a redelivered message moves no money. What amBrain can substantiate publicly is casino platform engineering, and a figure we publish as measured there is 12 operators live. The design below comes from the mechanics of the problem, not from a case of ours, and no number in it is measured on a system of ours.
Placement is a request with a person waiting: read market state, check a balance, write one bet, reply. Settlement starts from one result and fans out to every open bet on the affected markets at once. A big match ends while other events are still open, so that burst lands on the balance rows of accounts that are placing again.
If both paths write those rows in their own transactions, placement latency becomes a function of the longest settlement transaction on the same account. Decoupling is a set of promises about locks and time:
PostgreSQL's locking chapter says row-level locks block only writers and lockers of the same row, not readers, and that a transaction seeking a lock waits indefinitely unless a deadlock is detected. One row per account, updated on every placement, is therefore a queue, and correctly so: the lock stops two placements spending the same money. What matters is how long each holder keeps it.
Read Committed, the default isolation level, keeps the reservation simple. An UPDATE that finds a row already updated by a concurrent transaction waits for it to commit or roll back and, if it committed, re-evaluates its WHERE clause against the updated version. A conditional update that subtracts the amount only where the available balance covers it cannot oversell and needs no SELECT FOR UPDATE.
A settlement that marks every open bet on a market in one statement holds those row locks until commit and leaves a dead version of each row. The vacuuming chapter says an old version must not be removed while other transactions might still see it, so a long settlement, or a report idle in a transaction, keeps the whole burst on disk.
Autovacuum arrives late by design. PostgreSQL 18 vacuums a table once rows updated or deleted since the last vacuum exceed the smaller of autovacuum_vacuum_max_threshold and autovacuum_vacuum_threshold plus autovacuum_vacuum_scale_factor times the row count. With the defaults, 100,000,000, 50 and 0.2, a bets table of 50 million rows waits for about ten million updated or deleted rows.
Queue tables make the effect easy to see. In a 2015 post on brandur.org, Postgres Job Queues & Failure By MVCC, one transaction left idle beside a job queue raised the time to lock a job from under 0.01 seconds to peaks of 15 times that level, because dead job rows could not yet be removed.
Every connection is a backend process, and the documentation says raising max_connections, typically 100 by default, raises the resources sized from it, including shared memory. Give placement and settlement separate pools instead, so a settlement backlog queues for its own connections.
Replicas relieve reads at two costs. Streaming replication is asynchronous by default, so a commit becomes visible on the standby after a small delay. And the hot standby chapter says standby queries that conflict with vacuum cleanup from the primary are cancelled after a configured delay, while hot_standby_feedback prevents that by delaying cleanup on the primary, which may cause table bloat there.
Design placement backwards from its failure: a client times out and retries, and the retry must receive the first result, not create a second bet.
Partition storage by time and work by market. The partitioning chapter requires a unique constraint on a partitioned table to include all partition key columns, so the idempotency key either carries the partition column or lives in its own table. It also says the planner handles up to a few thousand partitions fairly well when queries prune all but a few, and markets are open-ended, so per-market partitions put planning time on the placement path.
The outbox row makes the event trustworthy. In the transactional outbox pattern as Chris Richardson describes it, the message is stored in the database within the transaction that updates the business entities, and a separate process sends it on. The same description names the cost: the relay may publish a message more than once, so consumers must be idempotent.
From the moment a result arrives, settlement is a backlog with an age, and nothing in it holds a row that placement waits on for longer than one batch:
Settlement is allowed to be late. It is not allowed to happen twice. Placement is allowed neither, and that is why the two cannot share a transaction.
One balance column cannot describe a bet that is accepted and not yet settled. Keep two numbers per account, available and reserved, and move money between them only through ledger entries that each carry a key:
Delivery can repeat: the outbox relay may republish, and when the outbox is read through logical decoding, the documentation says a slot can resend recent changes after a crash. So the requirement is an effect that happens once. Each ledger entry has a unique key, the balance update commits with the insert, and a redelivered message hits the constraint and moves no money.
Many reads at a peak sit beside placement rather than on it: open bets, history, balance screens refreshed after every event. Chris Richardson's description of CQRS serves such queries from a view database kept current by subscribing to events from the service that owns the data, and names replication lag and eventually consistent views as the cost. Placement's outbox already publishes those events.
Take the readings during the peak, on one time axis with placement latency:
Read together, they locate the fault. A growing pool queue with flat lock waits points at connections; lock waits rising with settlement batches point at shared rows; neither moving while dead rows climb points at the oldest transaction.
The second half of the question, which companies specialise in this, has a test that needs no vendor list. A firm that has separated these paths before does the following in a first conversation:
An answer that stays general on any of these means the work would start without a diagnosis.
So the first decision is not a bigger database. It is which mechanism owns the latency on the night placement slows down, and whether placement and settlement still share a transaction anywhere on the path.
What amBrain can substantiate publicly: amBrain is a software development company specializing in trading platforms, matching engines, real-time bidding systems, and casino platform engineering. amBrain has been building software since 2019. A figure we publish as measured in iGaming is 12 operators live. We work in three formats: full delivery, a dedicated team, or engineers embedded in your team.
Bring your current architecture and the failure mode that worries you, and we will go through it together in half an hour.