How do you stop two concurrent requests from spending the same balance twice?
Serialise per account rather than globally. Processing one user's operations strictly one at a time inside a database transaction removes the race within that account, while different accounts still run fully in parallel. The guarantee costs almost nothing because the lock is scoped exactly to where correctness needs it.
System architecture · Gaya Wallet
What the race actually looks like
A user fires two swaps within milliseconds. Request A reads the balance and sees 100. Request B reads the balance and also sees 100, because A has not written yet. Both conclude there are sufficient funds. Both proceed.
The wallet has now spent 200 it did not have. Nothing threw an error, both requests were individually correct, and the bug only appears under concurrency you cannot reproduce on demand.
Why is a transaction alone not always enough?
Because a transaction guarantees atomicity, not isolation from every anomaly. Under the default isolation level most databases ship with, two transactions can both read a value before either writes, and both will commit happily.
Raising the isolation level does help, and at serialisable you get correctness at the cost of aborted transactions your application now has to retry. That is a legitimate approach. It is also easy to get subtly wrong, because the retry path is exercised rarely and therefore tested rarely.
Scoping the lock
The insight is that the constraint is per account. Two operations on the same balance must not interleave. Two operations on different balances have nothing to do with each other.
So the sequencer serialises by user. One account's swaps queue behind each other and execute inside a transaction; every other account runs completely unimpeded. Correctness where it is needed, concurrency everywhere else.
This is also why it still scales horizontally. No replica needs to coordinate with another about a different account, so the fleet can autoscale freely. A global lock would be equally correct and would cap the entire platform at one worker.
What else has to go through it
Everything that touches the same balance, which is more than the obvious path. A scheduled dollar-cost-averaging buy competes with a manual swap. A bridge in flight holds value that is in neither place. A payout draws on the same funds.
If any of those bypass the sequencer, the guarantee is gone, because a race needs only two participants and it does not care that one of them is a cron job.
How we have used it
Gaya Wallet is non-custodial across more than sixteen chains, quoting six DEX aggregators in parallel for the best route and then handing the winner to a per-user sequencer that executes inside a database transaction.
Swaps, bridges, DCA and payouts all pass through it. The platform autoscales on Kubernetes from one replica to ten precisely because the ordering guarantee is per user rather than global.
COMMON QUESTIONS /
Questions people also ask
Is a database transaction enough to prevent race conditions?
Not at default isolation levels. Two transactions can both read a balance before either writes and both commit. Serialisable isolation does prevent it, at the cost of aborted transactions your code must retry correctly, which is a path that gets little real-world exercise.
What is the difference between optimistic and pessimistic locking?
Optimistic assumes conflicts are rare: you read, then check on write that nothing changed, and retry if it did. Pessimistic takes the lock up front so the conflict cannot happen. Optimistic is better under low contention; pessimistic is more predictable when the same row is genuinely contested.
Does serialising per user hurt throughput?
Barely. Only operations on the same account queue behind each other, and one user rarely fires many at once. Because no worker needs to coordinate with another about a different account, replicas still scale independently, so total throughput is governed by fleet size rather than by the lock.
Do scheduled jobs need the same locking as user requests?
Yes. A cron-driven purchase touches the same balance as a manual one, and a race needs only two participants regardless of what triggered them. Any path that bypasses the sequencer reopens the hole it was built to close.