Most engineering teams operate on an assumption they have never stated out loud: if a single request behaves correctly, a thousand simultaneous copies of that request will behave correctly too. A checkout flow gets tested by one QA engineer clicking "Pay Now" once, watching the charge appear, and moving to the next ticket. A booking flow gets tested by reserving one appointment slot and confirming it shows up on the calendar. An account signup gets tested by creating one user and checking the database. Every one of these tests passes. Every one of these tests is answering a question the production system will rarely be asked, because production systems are not visited by one user acting alone. They are visited by many users, many background jobs, and many retrying clients, all reaching for the same row, the same counter, the same inventory count, at nearly the same instant.
That gap — between "this works when I do it once" and "this works when two people do it at the same time" — is where a specific and expensive category of software defect lives. Double-charged customers. Two people holding a confirmed reservation for the same seat. An inventory count that goes negative. Two accounts silently created for the same email address. A discount code that gets redeemed thousands of times because nothing stopped two requests from checking its remaining balance at once. These are race condition bugs, and race condition testing is the discipline built specifically to find them before customers do.
The uncomfortable part is not that these bugs exist. Every nontrivial system has a few. The uncomfortable part is how confidently a system can look correct while carrying them. A feature can pass code review, pass a full regression suite, pass manual QA, and sit in staging for months without a single symptom — and then fail in production on its first real day of concurrent use, not because anything changed in the code, but because production is the first environment that ever asked two things to happen at once.
This article is about why that happens, how to go looking for these bugs on purpose, and what it costs an organization when nobody does.
What a Race Condition Actually Is
A race condition is not a bug in the traditional sense of a wrong calculation or a missing validation. It is a bug in the ordering of events. The code is frequently correct in isolation — every line does what it says — and the defect only appears when two executions of that code interleave in a specific, unlucky sequence.
The clearest way to see this is through the pattern that causes the overwhelming majority of real-world race condition incidents: the non-atomic read-modify-write sequence. Almost every business operation that involves a limited resource — an inventory count, an account balance, a seat, a coupon's remaining uses, a rate limit counter — follows some version of this logic:
- Read the current value.
- Decide, based on that value, whether the operation is allowed.
- Write the new value back.
Written as a single sentence, this looks harmless. Written as three separate steps executed by a computer, it is a trap. Between step 1 and step 3, the value the code is relying on can change, because another request is running the same three steps against the same row at nearly the same time. Both requests read the same starting value. Both requests independently decide the operation is allowed, because from each request's point of view, nothing has changed yet. Both requests write back a result that assumed it was the only one making the decision.
This specific shape — check a condition, then act on it, with a gap in between where the underlying state can shift — has a name in security and systems engineering: time-of-check to time-of-use, or TOCTOU. The term originated in Unix systems programming, describing the gap between a program checking whether it has permission to write to a file and the program actually writing to it — an attacker could swap the file for a symbolic link in that gap and redirect the write somewhere dangerous, such as a system password file. The mechanism generalizes far beyond filesystems. Any code that checks a condition and then acts on it, without guaranteeing nothing else can act on the same state in between, has a TOCTOU gap. An inventory check followed by a database insert has one. A "does this email already exist" query followed by a "create the account" insert has one. A "is this coupon still valid" read followed by an "apply the discount" write has one.
It helps to be precise about what makes this different from an ordinary logic bug. An ordinary bug is deterministic: given the same input, it produces the same wrong output every time, which is exactly why deterministic, sequential testing is so effective at catching it. A race condition is the opposite of deterministic. Given the same input executed twice in a row by a single tester, it may produce the correct result both times, because a lone tester clicking a button twice in succession — even quickly — still leaves a gap of hundreds of milliseconds between the two actions, more than enough time for the first request's read-modify-write cycle to complete before the second one starts. The bug requires the two operations to overlap inside a window that is often measured in single-digit milliseconds. A human cannot reliably produce that window. A test script that runs assertions one after another cannot produce it either, because sequential execution is, by definition, the absence of overlap.
This is the core reason race conditions belong in a category of their own rather than being treated as an unusually rare species of ordinary bug. The defect does not live in any single line of code that a reviewer can point to and say "this is wrong." It lives in the relationship between two executions of the code, a relationship that only exists at runtime, only under specific timing, and only when the system is asked to do more than one thing at once against the same piece of state.
Why These Bugs Are Invisible to Normal QA
If race conditions were merely rare, they would not deserve a dedicated article. They deserve one because the standard software delivery pipeline is, by construction, biased toward never triggering them — not because anyone designed it that way, but because every stage of that pipeline optimizes for something that happens to suppress concurrency.
Manual QA is inherently serial. A tester working through a test case executes one action, observes the result, and executes the next action. Even when a test plan includes a note like "verify duplicate submissions are handled," a human tester expressing that as two clicks separated by conscious thought will almost always leave enough of a gap for the first request to fully resolve before the second begins. The tester is not failing at their job. They are doing exactly what the test case describes. The test case itself does not know how to specify "these two actions must begin within five milliseconds of each other," because that is not how humans describe test steps, and it is not how humans physically interact with a mouse and keyboard.
Automated functional and regression suites are serial too, just faster. A typical end-to-end or API test suite executes test cases one after another, or in parallel across independent test files that do not share state. Parallelizing test execution — running test file A and test file B on different workers simultaneously — is a common and valuable practice for cutting CI time, but it is not the same thing as parallelizing requests against the same resource. Test file A and test file B are almost always designed to be independent of each other precisely so they can run in parallel without interfering. That independence is a feature for keeping CI fast and deterministic. It is also exactly what prevents the suite from ever generating two requests that collide on the same row.
Code review cannot see runtime interleaving. A reviewer reading a pull request sees the code as a linear sequence of statements. Nothing in a diff view shows what happens if a second, unrelated request executes the same function concurrently on a different thread, process, or server instance. A reviewer can catch an obviously missing null check. A reviewer cannot, by reading the code, simulate what a database's transaction isolation level will actually do when two connections touch the same row at the same moment — that behavior depends on the database engine, the isolation level configured for that connection, and the specific statements involved, not on anything visible in the diff.
Staging environments have essentially no real concurrency. Staging exists to verify that a build works, and it is typically visited by a handful of internal users and automated smoke tests, not by thousands of real customers acting independently. Low traffic means a low probability that any two operations will happen to land inside the same few-millisecond window on the same row, even if the underlying code has zero protection against it. The bug is present in staging. It is just statistically unlikely to fire there, in the same way a two-sided coin with a tiny weighted bias will still occasionally show consistent results over a small number of flips. Staging gives a false sense of safety not because it is unrealistic in its business logic, but because it is unrealistic in its concurrency profile.
Standard load testing measures a different question entirely. This deserves its own emphasis because it is the most common source of false confidence among teams that believe they have already covered this ground. A load test that ramps up to a thousand concurrent virtual users and confirms the system holds an acceptable response time and error rate under that volume is answering "does this system stay fast and available under heavy traffic?" It is not answering "does this system corrupt shared state when two specific requests touch the same row at the same instant?" A load test's virtual users are typically hitting a wide spread of different resources — different accounts, different products, different sessions — precisely so the test reflects realistic, diverse traffic. That diversity is exactly what makes a collision on one specific resource rare, even at high volume. A system can pass a load test with flying colors, sustaining thousands of requests per second with a clean error rate, while still being one unlucky pair of overlapping requests away from a double-charge on a single customer's account, because that one pair of requests targeting the same row was never deliberately constructed by the test.
CI environments themselves dampen timing variance. Modern CI runners are often resource-constrained, and test frameworks add their own latency between steps — network calls, database round-trips, assertion overhead. All of this tends to widen the natural gap between two operations rather than narrow it, making an accidental collision even less likely to occur by chance during automated testing than it is in a fast, well-resourced production environment.
Put together, this explains a pattern many engineering leaders have lived through without naming it: a feature ships, tests green across the board, and then fails in a way "no test could have caught," typically described afterward as bad luck or an edge case. It was not bad luck. It was the first time the operation was exposed to genuine concurrency, because every environment before production had been quietly, structurally arranged to avoid producing it.
The Technical Mechanisms Behind the Bug Class
Understanding the shape of the problem in the abstract is useful, but finding and fixing these bugs requires understanding the specific technical mechanisms that create them. There are five recurring patterns.
Non-atomic read-modify-write sequences
This is the mechanism already introduced above, and it is worth restating in concrete terms because it is the single most common root cause. Application code frequently expresses business logic as separate statements — a SELECT to check a balance or count, application-layer logic to evaluate whether the operation is allowed, and then an UPDATE or INSERT to record the result. Each statement is safe on its own. The danger is the gap between them, during which the database has no idea that the application intends these statements to be treated as one indivisible unit, unless the application explicitly tells it so.
Missing or insufficient database transactions
A transaction groups multiple statements so they either all succeed or all fail together, and — depending on isolation level — can also control what other concurrent transactions are allowed to see or do to the same rows while it runs. Wrapping a read-modify-write sequence in BEGIN ... COMMIT is a necessary step, but it is not sufficient by itself. According to PostgreSQL's own documentation, the database's default isolation level, Read Committed, only guarantees that a transaction never sees uncommitted data from another transaction — it does not prevent two concurrent transactions from each reading the same committed value, both proceeding as if their read is still current, and both writing back results that silently overwrite or ignore each other's work. A transaction without an appropriate isolation level or explicit row locking provides much less protection than its presence in the code might suggest to a reviewer.
Isolation level gaps
Relational databases offer multiple isolation levels precisely because stronger protection has a cost, and PostgreSQL's documentation lays out the trade-off clearly: Read Committed (the default) permits nonrepeatable reads and phantom reads; Repeatable Read, implemented through snapshot isolation, prevents both of those but can still allow certain serialization anomalies in complex multi-row scenarios, and requires the application to retry a transaction if PostgreSQL detects a conflicting concurrent update; Serializable, implemented through serializable snapshot isolation with predicate locking, provides the strongest guarantee — behavior equivalent to some serial execution of the concurrent transactions — but at the cost of more retries under contention, since the database will abort a transaction rather than allow it to produce a result inconsistent with any possible serial ordering. Many teams write their read-modify-write logic assuming a level of protection that the database's actual configured isolation level does not provide, often because nobody on the team has ever needed to look up what the default isolation level actually guarantees until an incident forces the question.
MySQL's InnoDB storage engine takes a related but distinct approach. Its documentation describes locking reads — SELECT ... FOR UPDATE and SELECT ... FOR SHARE — as a mechanism for explicitly acquiring a lock on rows at read time, specifically to prevent another transaction from modifying or deleting a row between when it is read and when the application acts on it. SELECT ... FOR UPDATE behaves like an UPDATE statement for locking purposes: it acquires an exclusive lock that blocks other transactions from updating those rows, or from acquiring their own share lock, until the current transaction commits or rolls back. This is a direct, targeted answer to the TOCTOU gap, but only if a developer thinks to add it — a plain SELECT followed by application logic and a separate UPDATE, without the locking clause, remains exposed.
Idempotency key gaps
Race conditions are not limited to two different users acting on the same resource. A single user's own client can trigger the same collision against itself — a double-click on a submit button, a mobile app retrying a request after a slow or dropped network response without knowing whether the original request actually succeeded, or a browser tab left open and refreshed mid-submission. Each of these produces two nearly-simultaneous requests carrying the same business intent. Without a mechanism for the server to recognize "I have already processed this exact request," it will process both, because from the server's point of view they are simply two separate, valid requests that happen to arrive close together. Idempotency keys — a unique identifier the client attaches to a request so the server can recognize and safely return the result of an already-processed attempt rather than repeating it — are the standard mitigation for this specific sub-case. Stripe's own documentation describes the mechanism precisely: the client generates a key, attaches it to the request, and the server saves the resulting status and body of the first request made under that key so that any retry with the same key returns the original result rather than performing the operation again; Stripe notes it retains keys for at least 24 hours before they can be pruned, and that it deliberately does not save an idempotent result for a request that fails validation or that conflicts with another request already executing concurrently under the same key, specifically so the client can safely retry those cases. It is worth being precise about scope here: idempotency keys solve the problem of the same logical request arriving more than once. They do not, by themselves, solve the broader problem of two different requests — from two different users, or two different parts of a single checkout flow — legitimately colliding on the same shared resource, such as two different customers both trying to book the last seat. That broader problem still requires the database-level protections described above.
Distributed and multi-instance concurrency
Nearly every production system worth discussing runs more than one instance of its application code — multiple containers, multiple server processes, multiple serverless function invocations — behind a load balancer, specifically so it can handle real traffic volume and survive individual instance failures. This is good architecture, and it is also precisely what makes race conditions more likely in production than in almost any pre-production environment, where a single developer or tester is usually running a single instance. Two customer requests that collide within milliseconds are, in production, often being handled by two completely separate application processes that have no shared memory, no awareness of each other, and no way to coordinate except through whatever shared state they both ultimately touch — typically the database, sometimes a cache, occasionally a message queue. Any in-process safeguard, such as a lock implemented purely with an in-memory mutex inside a single application instance, protects against collisions within that one instance and does nothing at all to prevent a second instance from causing the exact same collision.
This matters specifically for autoscaling and serverless architectures, where the number of running instances changes continuously based on traffic. A team that tested a feature under light load, when autoscaling had settled on a single running instance, can genuinely see an in-process lock behave correctly — and then watch the same code fail once traffic grows enough that a second instance spins up, because the fix was never actually protecting the shared resource, only masking the symptom for as long as there happened to be exactly one process running. This is a particularly easy trap to fall into precisely because the fix appears to work in every test performed before the system scales out, which is often the majority of a feature's early life.
Cache-based races
A related but distinct mechanism appears in systems that use a cache — Redis, an in-memory application cache, a CDN edge cache — as an intermediate layer in front of the database, typically to avoid a database round-trip on every read. The common "cache-aside" pattern reads from the cache first, falls back to the database on a miss, and writes the result back into the cache for next time. Under concurrent access, two requests can both miss the cache at the same moment, both query the database, and both write back to the cache — usually harmlessly, since both are writing the same correct value. The more dangerous version occurs when a write operation invalidates or updates the cache in a separate step from updating the database itself: a request that updates the database and then updates the cache can be interleaved with a second request that reads a stale cached value in between those two steps, or a cache invalidation can race with a still-in-flight read that repopulates the cache with the value that is about to become stale. The practical implication for testing is that any operation relying on a cache as part of its write path deserves the same synchronized-concurrent-request scrutiny as a direct database operation, since the cache does not inherit the database's transactional guarantees simply by sitting in front of it.
A Field Guide: What This Bug Class Looks Like in Production
The mechanisms above are abstract. The failures they cause are not. The following are the recurring, concrete manifestations engineering and QA teams encounter, along with the underlying pattern driving each one.
| Symptom | Underlying pattern | Typical shared resource |
|---|---|---|
| Customer charged twice for one order | Duplicate submission not deduplicated; or two payment-processing workers pick up the same job | Payment/order record |
| Two customers hold a confirmed booking for one seat, table, or slot | Non-atomic "check availability, then insert" without a lock or unique constraint | Inventory/capacity count or slot row |
| Inventory count goes negative | Read-then-decrement without an atomic conditional update | Stock quantity column |
| Two accounts silently created for the same email | "Check if exists, then insert" without a unique constraint enforced at the database level | User/account table |
| A single-use coupon or referral code redeemed far more than once | Remaining-uses check and decrement are not atomic | Coupon/promotion balance |
| Account balance or loyalty points withdrawn twice from one available amount | Balance check and deduction are two separate statements | Balance/ledger row |
| A background job processes the same record twice | No row-level lock or "claimed" flag set atomically when a worker picks up a job | Job queue row |
| A rate limit or usage cap is quietly exceeded | Counter increment is read-then-write instead of an atomic increment | Usage counter |
A pattern is visible across every row of that table: the shared resource is always something with a finite, business-meaningful limit — money, seats, accounts, uses, balance, exclusivity. That is not a coincidence. Race conditions are invisible on operations with no constraint to violate; nobody notices two reads of a blog post happening at the same millisecond, because there is nothing to corrupt. The bug class concentrates precisely where the business cares most about correctness, which is part of why it is disproportionately expensive relative to how often it occurs.
It is also worth naming the adversarial dimension explicitly, because it changes who needs to care about this beyond QA and backend engineering. Every pattern in that table can be triggered accidentally by ordinary user behavior — a slow connection, an impatient double-click, two family members trying to book the same appointment at once. Every one of them can also be triggered deliberately by someone who has specifically learned that a system has this weakness. Coupon and promotional-balance races, in particular, are a well-documented category of abuse: an attacker who discovers that a discount code's remaining-uses check is not atomic can script dozens or hundreds of simultaneous redemption requests and reliably get most of them approved before the count catches up, because the whole point of the exploit is to land inside the same TOCTOU gap on purpose, over and over, rather than by accident. OWASP's own material on race condition vulnerabilities treats this as a security-relevant class of bug for exactly this reason, not merely a reliability nuisance — a race condition is, from a security standpoint, a way to make a system approve an action it was explicitly designed to deny.
A Hypothetical Walkthrough: The Booking System That Never Overbooked — Until It Did
The following scenario is a hypothetical constructed to illustrate the mechanism described above. It does not describe a QAtronic client, engagement, or real incident.
Initial situation. A scale-up SaaS company runs a scheduling product used by service businesses to let their own customers book fixed-capacity appointment slots — a specialist consultation, a class, a fitted-capacity workshop session. Each slot has a hard capacity, frequently just one. The booking endpoint's logic, written early in the product's life and unchanged since, does the following when a customer clicks "Confirm Booking": it queries the current number of confirmed bookings for that slot, compares that count against the slot's capacity, and, if the count is below capacity, inserts a new booking row. The feature has been in production for over a year. It has never produced a visible overbooking incident. The engineering team considers it stable, mature code that does not need attention.
The hidden assumption. The team's confidence rests on an assumption nobody ever stated explicitly: that two customers will not click "Confirm Booking" for the same slot within the same few milliseconds. For most of the product's life, that assumption held simply because most slots are not popular enough to attract genuinely simultaneous interest — one customer books a Tuesday afternoon slot, and by the time anyone else looks at the calendar, it is already gone. The check-then-insert logic was never actually safe; it simply was rarely tested by real concurrent demand.
The technical and organizational cause. The endpoint's read-modify-write sequence — count bookings, compare to capacity, insert — is not wrapped in a transaction with row-level locking, and there is no database-level constraint preventing more bookings than capacity from existing simultaneously. Organizationally, this passed review at the time because the reviewer focused on whether the business logic for computing "is this slot full" was arithmetically correct, which it was. Nobody on the review evaluated what would happen if two of these requests executed concurrently, because nothing in the code, the ticket, or the test plan raised that question. QA verified the feature by booking a slot, confirming it appeared as reserved, and confirming a second booking attempt on the same now-full slot was correctly rejected — a test performed sequentially, with the first booking fully completed before the second attempt began. That test passed and continues to pass today; it was never the right test for this failure mode.
The consequence. A particular slot — a highly sought after limited-capacity session announced by the service business to its own mailing list — receives a burst of near-simultaneous booking attempts the moment it opens. Two customers, arriving within the same narrow window, both query the slot, both see it has zero existing bookings against a capacity of one, and both proceed to insert a confirmed booking. Both receive confirmation emails. Both show up. The service business now has to turn one paying, confirmed customer away, absorb the reputational cost of that conversation, and escalate a support ticket to the SaaS provider asking how a "confirmed" booking could be double-sold — a question the provider cannot answer by pointing to any passing test, because the test suite has no failing test to point to.
The decision that needs to be made. The engineering team faces a choice that is more organizational than technical. The narrow fix — add a database constraint or locking read to this one endpoint — is straightforward once the mechanism is understood. The harder decision is whether to treat this as an isolated bug to patch, or as a signal to audit every other endpoint in the codebase that follows the same check-then-act shape against a limited resource, since the same reviewing and testing gaps that allowed this one through were almost certainly not unique to it.
The better approach. The team converts the booking check into a single atomic operation the database can enforce on its own, rather than trusting application code to enforce it across two separate round-trips. In practice this means one of two things: either wrapping the check and insert in a transaction that takes a row or advisory lock on the slot before counting existing bookings (preventing a second transaction from reading a stale count), or — more robustly — expressing the capacity rule as a database-level constraint, such as a unique constraint on slot-plus-seat-number for single-seat slots, or a conditional atomic update against a remaining-capacity column (UPDATE slots SET remaining = remaining - 1 WHERE id = ? AND remaining > 0, checking that the update actually affected a row before confirming the booking). This shifts the guarantee from "the application code happened to check first" to "the database physically will not allow the invariant to be violated, regardless of how many requests arrive at once." The team then extends the same audit to every other endpoint with a finite-resource check-then-act pattern, rather than treating the fix as complete once the one reported symptom is resolved.
How to Deliberately Test for Race Conditions
Everything above explains why these bugs hide. This section is about going and finding them on purpose, which requires a genuinely different testing posture than functional QA — the goal shifts from "does this operation produce the correct result" to "does this operation produce the correct result when it is not the only one running."
Distinguish concurrency testing from throughput load testing
This distinction is worth stating as a first principle because conflating the two is the most common reason teams believe they already have this covered. Throughput load testing asks how a system behaves under volume — response times, error rates, resource utilization — typically by simulating many virtual users spread across a realistic mix of different actions and different resources. Concurrency testing asks a narrower and more targeted question: what happens when two or more specific operations are made to land on the same resource at nearly the same instant. A concurrency test does not need thousands of virtual users. It often needs exactly two, aimed with precision at the same row, fired as close to simultaneously as the test harness can manage. Volume is not the variable that matters here; collision is.
Synchronized concurrent request injection
The core technique is deliberately engineering two or more requests to start within a window narrow enough to reliably trigger the race, rather than hoping ordinary test timing produces one by accident. This typically looks like a small script, not a full load-testing framework: acquire the resource identifiers involved (the same slot ID, the same coupon code, the same user email), prepare the requests in advance so no per-request setup work adds unpredictable delay, and use a synchronization primitive — a barrier, a wait group, or simply firing all requests from an async batch without awaiting them individually — so they are dispatched as close to simultaneously as the client and network allow. Tools built for scripted load generation, including general-purpose load testing frameworks like k6 or Gatling, or even a short script using a language's native concurrency primitives (goroutines, async tasks, threads), are all capable of this; the important design choice is not which tool, but making sure the test explicitly targets one shared resource with multiple simultaneous callers, rather than spreading load across many resources the way a realistic traffic simulation would.
A useful working pattern for this kind of test:
- Identify the specific shared resource and the specific endpoint(s) that read and modify it.
- Seed the resource into the exact state that makes the race condition possible — for example, a slot with exactly one remaining seat, or a coupon with exactly one remaining redemption.
- Fire N requests (N is often as small as 2, and rarely needs to be more than 10–20 for this class of bug) at that resource with the smallest achievable gap between dispatch times.
- Assert on the aggregate outcome, not any individual response — the total number of successful bookings against that slot, the final value of the coupon's remaining-uses counter, the final account balance — rather than checking that each individual response looked reasonable in isolation.
- Repeat the run multiple times. Because the bug is timing-dependent, a single run that happens not to trigger it proves nothing; the absence of a failure across one execution is not evidence of safety in the way it would be for a deterministic test.
That last point deserves emphasis on its own, because it is where teams new to this kind of testing most often get a false negative. A race condition test that passes once has told you the collision did not happen on that particular run, on that particular hardware, under that particular momentary load. It has not told you the underlying code is safe. Running the same concurrent test dozens of times, ideally under mildly varying conditions, is part of the technique, not an optional extra.
Widening the race window deliberately
Some race conditions have a genuinely narrow window — a handful of milliseconds — that is hard to hit reliably even with a well-built synchronized test, particularly across a network. A technique borrowed from fault-injection and chaos engineering practice addresses this directly: introduce a deliberate, temporary artificial delay inside the vulnerable code path during testing — for example, a short sleep inserted between the read and the write in the operation under test, active only in a test or staging build. This widens the TOCTOU gap from a few milliseconds to, say, a few hundred milliseconds, making it trivial for a synchronized test harness to land a second request inside the window reliably. This is a testing technique, not a production pattern — the artificial delay should never ship to production — but it is a legitimate and efficient way to convert "this might have a race condition that we can't quite land in practice" into "this reliably does or does not have a race condition," without needing an unrealistic number of test iterations to catch a narrow window by chance.
Database isolation level and locking review
Because the mechanism so often traces back to isolation level assumptions and missing row locks, a targeted code and configuration review is a distinct and valuable technique alongside runtime testing, not a replacement for it. This review specifically looks for:
- Read-modify-write sequences (a
SELECTfollowed by application logic followed by anUPDATEorINSERT) that are not wrapped in a transaction with a locking read (SELECT ... FOR UPDATEin MySQL/InnoDB or PostgreSQL) or an equivalent atomic conditional update. - The isolation level actually configured for the application's database connections, compared against what the surrounding code assumes it guarantees — a team that believes it is protected because "we use transactions" but has never checked whether those transactions run at Read Committed, Repeatable Read, or Serializable is a team operating on an assumption rather than a verified fact.
- Whether business invariants that could be enforced by the database itself — uniqueness, non-negative balances, capacity limits — are instead being enforced only in application code, which cannot guarantee atomicity across two separate database round-trips the way a database-level constraint can.
- Whether any in-process locking (a mutex, a synchronized block) is being relied upon as if it provides protection across multiple application instances, when it only protects against collisions within a single running process.
This review is best performed by someone reading the code with this specific question in mind — "what happens if this function runs twice, concurrently, against the same row" — rather than as a general code quality pass, because the pattern is easy to miss when reading for other things.
Testing across genuinely separate application instances
A synchronized concurrent test run from a single test script, against a single running instance of the application, is a legitimate and often sufficient way to expose a database-level race condition, because the database sees both requests as separate connections regardless of which process sent them. It is not, however, sufficient for catching a bug caused specifically by an in-process safeguard — an in-memory lock, a local cache, a counter held in a single process's memory — that only works because there happens to be one instance running. Verifying that a fix genuinely holds under real deployment conditions requires running the same synchronized test against an environment configured with multiple running instances behind a load balancer, matching how the system actually runs in production, rather than against a single local or staging instance where an in-process-only fix can pass undetected. This is a meaningful reason staging configurations that deliberately mirror production's instance count, even at smaller scale, catch a category of bug that a single-instance staging setup structurally cannot.
Idempotency key testing
Where a system uses idempotency keys to protect client-triggered retries, that mechanism deserves its own explicit test cases rather than an assumption that adding the header was sufficient: sending the same request twice with the same idempotency key and confirming the second response reflects the already-processed result rather than repeating the operation; sending two genuinely concurrent requests with the same key (not sequential, since a sequential pair only tests the simpler case) and confirming only one operation actually executes; sending the same key with different request parameters and confirming the system rejects the mismatch rather than silently applying one version, consistent with how Stripe's own documentation describes handling that case; and confirming keys are not reused indefinitely as unbounded storage, or handled in a way that creates its own resource exhaustion risk.
Automated race-condition regression tests in CI
Once a race condition is found and fixed, the concurrent test that reproduced it should be added to the regular test suite, run against the specific endpoint or function on every change to that code path, not just executed once during the original investigation and discarded. This is the concurrency equivalent of writing a regression test for any other confirmed bug — the difference is that most CI pipelines are not currently set up to run this kind of synchronized-concurrent test pattern by default, so it typically needs to be added as its own deliberate test category, distinct from the standard functional suite, precisely because the standard suite's execution model is what made the bug invisible in the first place.
Fixing and Preventing the Bug Class
Detection only has value if it leads to a fix that actually closes the window, rather than one that narrows it without eliminating it. The available approaches fall into a small number of well-understood patterns.
Push the invariant into the database
The most durable fix is almost always to stop relying on application code to enforce a business rule that the database can enforce atomically on its own. A unique constraint prevents two rows that should never both exist from both existing, regardless of how many concurrent requests try to insert them — the database itself will reject the second one, deterministically, with no race window at all. A conditional atomic update (UPDATE inventory SET quantity = quantity - 1 WHERE product_id = ? AND quantity > 0) combines the check and the write into one statement the database executes as a single indivisible operation, and the application simply checks whether the statement affected a row to know if the operation succeeded — there is no gap between check and use because there is no separate check. This pattern deserves to be the default instinct for any operation touching a limited, shared resource, before reaching for more complex locking strategies.
Locking reads within a transaction
Where the logic genuinely cannot be collapsed into a single atomic statement — because the decision requires reading and evaluating multiple related rows before deciding what to write — a locking read inside a transaction is the next tool. SELECT ... FOR UPDATE in MySQL/InnoDB or PostgreSQL acquires an exclusive lock on the selected rows for the duration of the transaction, so a second transaction attempting to read those same rows with its own locking read (or attempting to update them directly) is forced to wait until the first transaction commits or rolls back, at which point it sees the up-to-date state rather than the stale value it would have seen without the lock. This is a form of pessimistic concurrency control: it assumes collisions will happen and prevents them by blocking, rather than allowing them to happen and detecting them afterward.
Optimistic locking as an alternative
Pessimistic locking is not always the right default, particularly for resources with high read volume relative to write volume, because holding a lock for the duration of a transaction — however briefly — creates contention: every other request touching that row has to wait its turn, which can turn a popular resource into a serialization bottleneck under heavy concurrent demand. Optimistic locking takes the opposite stance: assume collisions are rare, allow all reads and writes to proceed without blocking, and instead detect a collision at write time using a version number or timestamp column. The read includes the current version; the write includes a WHERE version = <the version that was read> clause; if another transaction has modified the row in between, the version no longer matches, the update affects zero rows, and the application knows the operation needs to be retried against fresh data rather than silently succeeding on stale assumptions.
Choosing between the two is a genuine trade-off rather than a matter of one being simply better:
| Pessimistic locking | Optimistic locking | |
|---|---|---|
| Core mechanism | Acquire a lock before reading; block other transactions until done | Read freely; detect a conflicting write at commit time via a version check |
| Best suited to | High-contention resources where collisions are frequent and blocking briefly is cheaper than retrying | Low-to-moderate contention resources where most operations do not actually collide |
| Failure mode under heavy contention | Requests queue and wait; throughput drops as lock hold time and concurrent demand rise | Requests fail fast on conflict and must retry; retry storms are possible if contention is actually high |
| User experience on conflict | The second request simply waits slightly longer; no visible failure | The second request may need to be told to retry or refresh, which can surface to the user if not handled transparently |
| Implementation cost | Lower — a locking read clause and a transaction boundary | Slightly higher — requires a version/timestamp column and explicit retry logic in the application |
| Risk of deadlock | Present if multiple resources are locked in inconsistent order across different code paths | Not applicable — no locks are held |
A practical rule of thumb: pessimistic locking suits scarce, hotly contested resources with a small number of units — the last seat, the last unit of a limited drop — where blocking briefly is cheap relative to the cost of getting it wrong. Optimistic locking suits resources that are updated somewhat frequently but rarely by two actors at exactly the same instant — a user's own profile record, a document being edited, a shopping cart — where paying the cost of a lock on every read would be wasteful relative to how rarely a real conflict occurs.
The two approaches are not mutually exclusive across a single system, and mature codebases frequently use both, applied deliberately rather than uniformly. A single checkout flow might use optimistic locking on the shopping cart itself, where a user editing their own cart from two open tabs is a rare, low-stakes conflict best handled by a quick retry, while using pessimistic locking or an atomic conditional update on the final inventory decrement at the moment of purchase, where the stakes and contention are both far higher. The mistake worth avoiding is choosing one strategy as a team-wide default and applying it everywhere without asking which of the two failure modes — the cost of blocking, or the cost of a failed and retried write — is actually cheaper for the specific resource in question.
Serializable isolation with retry logic
For operations too complex to express as a single atomic statement or a simple locking read — those involving multiple interdependent checks across several rows or tables — running the transaction at the Serializable isolation level, as PostgreSQL implements it through serializable snapshot isolation, provides the strongest available guarantee: the database itself detects when a set of concurrent transactions could not have produced a result consistent with any serial ordering of those transactions, and aborts one of them rather than allowing a subtly wrong outcome to commit. The cost is that the application must be written to expect and gracefully retry these serialization failures — they are not exceptional edge cases to alert on, but an expected part of correct operation under this isolation level, and code that does not retry on them will simply surface intermittent, confusing failures to users instead of silently corrupting data.
Idempotency keys, correctly scoped
As covered earlier, idempotency keys solve the specific sub-problem of a single client's request arriving more than once due to a retry, a double-click, or a dropped response. They are a genuinely valuable and low-cost mitigation for that sub-problem, and worth implementing on any state-changing endpoint a client might retry — but they should not be mistaken for a general solution to concurrency between different actors. Two different customers racing for the same last seat are not sending duplicate requests under any shared key; they are sending two entirely legitimate, distinct requests that happen to collide on a shared resource, and no amount of idempotency key logic changes that — only the database-level protections above do.
Application-level and distributed locks, used carefully
Where the shared resource is not a database row — a distributed cache entry, a external API rate limit being coordinated across instances, a batch job that must not run twice concurrently — a distributed lock (commonly implemented via Redis or a dedicated coordination service such as ZooKeeper or etcd) can serve a similar role to a database row lock. These are genuinely useful tools, but they introduce their own correctness questions that a team adopting them should evaluate deliberately rather than assume away: what happens if the process holding the lock crashes before releasing it, how the lock's expiry is chosen relative to the operation's realistic duration, and whether the locking mechanism itself has a race condition in its acquire step. A distributed lock is a tool for solving this class of problem outside the database, not a shortcut that avoids needing to think through the same timing questions that apply everywhere else in this article.
The Concurrency Risk Assessment Framework
Not every endpoint in a codebase deserves the same level of concurrency scrutiny, and treating every read-modify-write sequence as equally urgent is a reliable way to exhaust a team's attention before reaching the operations that actually matter. The framework below was built for this article to give engineering and QA teams a structured way to triage which operations warrant dedicated concurrency testing, and how rigorously.
Score each operation that touches shared, mutable state across four dimensions, one to three points each.
| Dimension | 1 point | 2 points | 3 points |
|---|---|---|---|
| Shared state exposure | State is scoped to a single user/session, unlikely to be touched by another actor | State is shared across a small, known group (a team, an account) | State is globally shared or contested across all users (inventory, a single coupon, a capacity-limited slot) |
| Consequence of corruption | Cosmetic or easily correctable (a display counter, a non-critical log) | Operationally annoying but reversible (a support ticket, a manual reconciliation) | Financially or contractually binding, or hard to reverse (a charge, a confirmed booking, an account record) |
| Concurrency likelihood | Rarely accessed by more than one actor at a time in practice | Occasionally accessed concurrently during normal peak usage | Frequently or predictably accessed concurrently (flash sales, popular slots, high-traffic endpoints, retry-prone clients) |
| Existing protection maturity | Verified atomic operation, constraint, or locking read already in place and tested | Wrapped in a transaction, but isolation level and locking behavior have not been explicitly verified | No transaction, no locking read, and no database-level constraint — protection depends entirely on application-layer timing |
Total score 4–6: Low priority. Cover with standard functional testing. Revisit if usage patterns change (the operation becomes more contested, or moves from internal to customer-facing).
Total score 7–9: Moderate priority. Include in a periodic isolation-level and locking review. Add a synchronized concurrent test if engineering time allows; treat it as a candidate for the next concurrency testing cycle rather than an immediate blocker.
Total score 10–12: High priority. Requires dedicated synchronized concurrent request testing before the next release that touches this code path, and should have an automated regression test added to CI once verified. Any change to this code path should trigger a re-review of this score, not just a functional re-test.
This scoring exercise is deliberately fast — it is meant to take minutes per endpoint, not a full audit cycle — and its value is less in the precise numeric score than in forcing an explicit answer to four questions that most teams have never asked about most of their own code: who else might touch this state, what happens if it gets corrupted, how often will two actors actually collide on it, and what, concretely, is currently stopping that from going wrong.
The Business Cost of Timing-Dependent Bugs
The financial and trust cost of this bug class is easy to underestimate because each individual occurrence often looks small in isolation — one double-charged customer, one oversold seat — while the aggregate pattern across a growing product is not small at all.
Direct financial cost. A double charge requires a refund, and depending on the payment processor and dispute process, may also trigger a chargeback fee even when the merchant is clearly not at fault and resolves it promptly, because the customer's bank may initiate a dispute before the merchant has a chance to issue a refund. Oversold inventory or double-booked capacity creates a direct cost either in the physical world (the business genuinely cannot deliver what it confirmed) or in the make-good the business offers to the customer who has to be turned away or downgraded.
Support and operational cost. Every incident in this category tends to generate a disproportionate amount of support burden relative to its apparent scale, because the customer experience is not a generic error message but a specific, confusing contradiction — "your system told me this was confirmed" — that a support agent cannot resolve with a standard script. These tickets typically require engineering involvement to diagnose, because the support team has no visibility into a timing-dependent database race, only the visible symptom.
Trust cost, which compounds. A double charge or a broken booking confirmation is a different category of failure from a slow page load or a minor UI glitch, because it directly contradicts something the system explicitly told the customer was true and final — "payment successful," "booking confirmed." Customers reasonably treat those messages as commitments. A system that occasionally breaks that commitment, even rarely, trains its most engaged customers — the ones most likely to interact with popular, high-demand, high-concurrency features — to distrust its confirmations generally, which is a cost far broader than the specific incident that caused it.
Fraud and abuse surface. As covered in the field guide section, this bug class is not purely accidental. A system with an unprotected coupon-redemption or balance-check race is not just occasionally unlucky with legitimate concurrent traffic; it is exposed to anyone who deliberately scripts concurrent requests specifically to exploit the gap. This shifts part of the cost conversation from "a rare unlucky timing collision" to "a known, exploitable weakness that will be found and used systematically if it is discoverable," which is a materially different risk profile for finance and security stakeholders to weigh, not only engineering.
Engineering cost of late discovery. A race condition found through deliberate pre-release testing costs the time to write and run a synchronized concurrent test and apply one of the fixes described above. A race condition found in production costs an incident response, a root-cause investigation that is often harder than usual precisely because the bug does not reproduce reliably, a customer-facing remediation, and — frequently — a broader audit of "what else in this codebase has the same shape," undertaken under the added pressure of having just been burned by it once.
Compliance and audit exposure. For companies operating under financial or data-integrity obligations — PCI DSS scope for anything touching card data, SOC 2 commitments around processing integrity, or contractual SLAs with enterprise customers — a duplicate charge or a corrupted balance is not only a customer-facing problem but an audit finding waiting to happen. A duplicate financial transaction discovered during an external audit, rather than caught and explained by the engineering team beforehand, tends to invite a broader question from the auditor about what else in the system's controls has not been verified, which can extend the scope and cost of the audit itself well beyond the original finding.
None of this requires an invented statistic to make the case; the mechanism itself explains why the cost is structurally higher than the visible frequency suggests. This is a bug class where the ratio of business consequence to occurrence rate is unusually high, because it concentrates specifically on the operations — payment, inventory, account identity, capacity — where correctness has always mattered most.
Who Owns This, and How That Changes by Company Stage
Concurrency correctness sits awkwardly across conventional team boundaries, and naming an owner explicitly matters, because the default outcome — when no one is explicitly responsible — is that everyone assumes someone else is covering it.
Backend and platform engineering own the technical mechanism: transaction boundaries, isolation levels, locking strategy, database constraints. This is not optional expertise for a senior backend engineer working on any system with shared, contested state, but it is genuinely easy for it to remain implicit knowledge that lives with one or two people rather than being reflected in team-wide review standards.
QA and test engineering own making concurrency testing a deliberate, repeatable practice rather than a one-off investigation that happens only after an incident. This means building the synchronized-request testing pattern into the team's toolkit, applying the kind of risk triage described above to decide where it matters most, and maintaining regression coverage once a race condition has been found and fixed once.
Product and engineering leadership own deciding which operations are important enough to warrant this level of scrutiny in the first place, since not every team has unlimited time to concurrency-test every endpoint, and the risk assessment framework above is only useful if someone with authority over prioritization actually applies it and protects the time to act on high-priority findings.
How this plays out differs meaningfully by company stage.
Startups, particularly pre-product-market-fit, often make a reasonable and defensible trade-off: shipping fast with unprotected read-modify-write sequences, accepting the risk because traffic is low enough that the collision window is rarely hit in practice. The mistake is not making this trade-off — it is making it silently, without anyone recording which operations carry this risk, so that the moment traffic or a specific feature (a popular limited drop, a viral referral program) crosses the threshold where collisions start happening for real, nobody remembers there was ever a decision made, let alone which endpoints it applies to.
Scale-ups are typically the stage where this bug class first becomes genuinely painful, because traffic has grown enough that collision windows that were theoretical are now being hit regularly, but the codebase and team have often grown too large for any one person to know where every unprotected check-then-act pattern lives. This is the stage where the risk assessment framework earns its keep as a deliberate audit exercise, rather than waiting for each instance to be discovered one production incident at a time.
Enterprises usually have the isolation-level and locking discipline more consistently applied to their core transactional systems, simply because those systems have been through more incidents and more scrutiny over a longer history. The risk at this stage shifts toward newer, adjacent systems — a recently acquired product, a new microservice built by a team unfamiliar with the older system's hard-won conventions, an integration layer connecting two systems that each individually handle concurrency correctly but were never tested for what happens when they are both writing to a shared record at once.
Questions Executives Should Be Asking Their Engineering Teams
A leader does not need to personally evaluate isolation levels to ask the right questions and recognize a credible answer.
- Which of our operations touch a shared, limited resource — money, inventory, capacity, account identity — where two customers or two retries could plausibly collide?
- For those operations specifically, has anyone tested what happens when two requests hit them at nearly the same instant, as opposed to testing them one at a time?
- Are the business rules for those operations enforced by the database itself (a constraint, an atomic conditional update, a locking read), or only by application code that assumes it will be the only thing running?
- When we last had an incident that looked like "this shouldn't have been possible" — a duplicate charge, a duplicate account, an oversold item — did the investigation identify a timing-dependent root cause, and if so, was the fix applied only to that one endpoint or audited across similar ones?
- Do we have any repeatable way to test for this class of bug before release, or does our confidence rest entirely on the fact that it has not visibly happened yet?
A team with a genuinely credible answer to these can usually point to specific endpoints they have identified as high risk, specific protections in place, and at least one concrete example of a concurrency test they have actually run. A team without one tends to answer in general reassurances about "we use transactions" without being able to say which isolation level, or whether anyone has verified what that isolation level actually does under a real concurrent collision.
Frequently Asked Questions
Is a race condition the same thing as a bug caused by too much traffic? No. Traffic volume affects how often a race condition gets triggered, but the underlying defect exists regardless of load — it can be triggered by exactly two overlapping requests at any traffic level, including in a low-traffic staging environment, if the timing happens to line up. A high-traffic system with well-protected shared state can have zero race condition incidents, while a low-traffic system with an unprotected check-then-act sequence can still produce one the first time two real users happen to collide.
Can automated end-to-end test suites catch race conditions without any special effort? Generally not, and this is one of the more important points in this article. Standard end-to-end and API test suites execute test cases sequentially or across independent, non-colliding parallel test files, which is exactly the execution pattern that avoids triggering a race condition. Catching this class of bug requires tests specifically designed to fire multiple requests at the same shared resource within a tight timing window, which is a distinct testing activity from standard functional automation.
Do idempotency keys solve race conditions? They solve one specific and common sub-case — the same client accidentally or intentionally resubmitting the same logical request, such as through a network retry or a double-click. They do not solve collisions between two different, legitimate requests from different actors targeting the same shared resource, such as two different customers both trying to book the last available seat. That broader case requires database-level protections such as atomic conditional updates, unique constraints, or locking reads.
Does using a NoSQL database avoid this problem? No. The mechanism — a gap between checking a condition and acting on it, during which shared state can change — is not specific to relational databases or SQL. It applies to any data store where an application reads a value, makes a decision based on it, and writes a result back in a separate step. Some NoSQL systems offer their own atomic operations (conditional writes, atomic counters) that can be used to close the gap, but the underlying risk and the need to deliberately close it are the same regardless of database type.
How many concurrent requests do we actually need to fire in a test to catch this reliably? Often surprisingly few. Because the defect is about two operations colliding inside a narrow window rather than about volume, a well-synchronized test firing just two to ten requests at the exact same resource, repeated across several runs, is frequently enough to reliably reproduce a genuine race condition — assuming the requests are actually dispatched close enough together, which matters far more than the raw count.
Is this only a concern for payments and e-commerce? No. It shows up anywhere an application enforces a rule about a shared, limited resource — account creation and uniqueness, rate limiting, job queue processing, feature usage caps, capacity-limited scheduling, content moderation locks, and internal administrative tooling all follow the same check-then-act shape and carry the same risk if that shape is not made atomic.
We pass load testing at high volume with a clean error rate. Doesn't that mean we've already tested for this? Not on its own. A load test spreading traffic realistically across many different resources is unlikely to generate two requests that happen to collide on the exact same row within the exact same narrow window, even at high overall volume, because collision probability depends on how concentrated the traffic is on one specific piece of shared state, not on the total request count. A system can sustain a clean, fast load test result while still having zero protection against two requests deliberately aimed at the same resource at the same instant — which is precisely the scenario a dedicated concurrency test is built to create on purpose.
If we add a transaction around a read-modify-write sequence, is that enough by itself? Not necessarily. A transaction guarantees that its own statements succeed or fail together, but what it guarantees about what a concurrent transaction can see or do to the same rows depends on the isolation level the transaction runs at. At a database's default isolation level, which for PostgreSQL is Read Committed, two concurrent transactions can each read the same value, each proceed as if it is still current, and each write back a result that overwrites or ignores the other's work — the transaction boundary alone does not prevent this. Closing the gap requires either a stronger isolation level with retry logic, an explicit locking read, or an atomic conditional update, not just wrapping the statements in BEGIN and COMMIT.
Conclusion
Sequential testing answers a real and necessary question: does this feature work. It does not and cannot answer a different question that matters just as much for a certain class of operation: does this feature stay correct when it is not the only thing happening. Those are not the same question, and treating a passing regression suite as proof of the second one is the specific assumption that fails at scale, quietly, until the exact week a feature becomes popular enough for two people to want the same thing at once.
The principle worth carrying back to an engineering team is narrower and more actionable than "test for concurrency in general." It is this: any operation that reads a piece of shared, limited state and then writes a decision back based on that read is not safe by default, no matter how many times it has passed a test where only one request ran at a time — and the only way to know whether it is actually safe is to make two requests hit it at once, on purpose, and watch what happens to the total, not to either response individually.
The question worth taking back to a team this week is not abstract. Pick the single operation in the product where a timing collision would be most expensive — the one touching money, capacity, or identity that a customer would notice going wrong — and ask, plainly, who has actually verified what happens when two of those requests land at the same instant, and how they verified it. If the honest answer is "we haven't," that is not a hypothetical risk. It is a known, specific gap with a name, a well-understood set of fixes, and a way to test for it deliberately, rather than waiting to find out from a customer.
Teams that recognize this gap in their own systems but do not have spare engineering bandwidth to build synchronized concurrency testing from scratch do not need to solve it alone. QAtronic works with engineering teams to identify which operations in a product carry real concurrency risk, design synchronized concurrent-request test scenarios around those specific operations, and review transaction and locking strategy against what a system's actual traffic patterns require — as a complement to, not a replacement for, a team's existing functional and load testing.