Two requests. One remaining place. Who wins?
Preventing WordPress race conditions with atomic writes, explicit invariants, version checks and transactions that account for uncertain outcomes.
Two PHP requests can each follow the rules and still produce a result that breaks them.
A booking site has one appointment remaining. Two visitors submit at almost the same moment. Each request reads “one available”, checks that it is greater than zero and creates a reservation. The problem is not an incorrect comparison. It is the gap between observing the condition and committing the action.
The same shape appears in limited-use coupons, directory quotas, membership upgrades and background job claims. WordPress handles requests in separate workers; a variable or a static property inside one request cannot coordinate them. Correctness has to survive their interleaving.
Write the invariant before choosing a lock
An invariant is the rule that must remain true across all execution orders. For a booking system, it might be that confirmed reservations plus live holds never exceed capacity. For a credit balance, it might be that no committed debit makes the balance negative.
Be precise about holds, expiry and cancellation. “Only one person can book” is incomplete when a payment may take several minutes, a hold may expire and a late success notification may still arrive. Define the states and which transitions consume or release capacity.
Then identify every writer: the public form, an admin adjustment, an import, a webhook and a repair command. A guarantee enforced only in the public form is a convention that the next integration can bypass.
Move the condition into the write
For a single inventory row in an InnoDB table, the database can evaluate the condition as part of the update. The primary key should identify the slot. A simplified claim looks like this; the table name and slot ID are illustrative.
UPDATE wp_slot_inventory
SET remaining = remaining - 1,
version = version + 1
WHERE slot_id = 42
AND remaining > 0;The application must inspect the affected-row result. One changed row means this statement claimed capacity. Zero means it did not: the slot may be exhausted or absent. A database failure is another outcome entirely. Do not turn a failed query into an ordinary “sold out” response and hide an outage.
WordPress’s database query method returns an affected-row count for updates or false on error. Compare these outcomes explicitly. Prepare external values with $wpdb->prepare(), and derive table identifiers from trusted application configuration.
This statement is a capacity claim, not a complete booking implementation. If a reservation row must accompany it, make the claim and insertion part of the same transaction on transactional tables and the same connection. Roll back both if either step fails. Check commit failures as well as statement failures.
Use locking reads when the decision spans multiple facts
Some transitions cannot be expressed as one conditional update. A booking might need to inspect an existing hold and an inventory row together. A transaction with appropriately indexed locking reads can protect the facts that the transition depends on.
InnoDB’s SELECT ... FOR UPDATE holds relevant locks until the transaction ends. A regular SELECT does not provide the same protection for a subsequent update. Transactions and lock scope matter; merely adding the words to a query in an otherwise unsuitable flow does not establish the desired guarantee. See the locking-read reference.
Locking an existing row is different from preventing a conflicting new row from appearing. Express uniqueness through an appropriate unique constraint where possible. A table without a unique booking key can still accept duplicate reservations through a writer that does not follow your lock convention.
Protect edits with a version, not a last-write-wins surprise
Concurrency is also an editorial problem. Two administrators can open the same record, make different changes and overwrite one another. WordPress’s editing interface has its own coordination mechanisms, but a custom table or external API needs an explicit contract.
For an application-owned record, include a version in the form or API response and require it on the next update. This example changes a label only if the record is still at the version the editor saw.
UPDATE wp_slot_inventory
SET label = 'Afternoon consultation',
version = version + 1
WHERE slot_id = 42
AND version = 17;Zero changed rows means the expected state is no longer available, or the record does not exist. Fetch an authorised current representation and offer a conflict resolution path. Blindly retrying with the latest version would defeat the check and overwrite the other person’s edit anyway.
Idempotency solves a different problem
Atomic capacity checks stop two competing claims from exceeding a limit. They do not stop one visitor’s retried request from creating two valid bookings when capacity is greater than one.
Use an idempotency key scoped to the authenticated actor and operation, backed by a unique constraint. Bind it to a canonical request payload so reusing the key with different details is rejected. Preserve the completed result so a retry can return the existing booking rather than perform the operation again.
The idempotency record, capacity transition and booking should share a clearly defined commit boundary where the storage model permits it. Remote payment processing cannot simply join that database transaction. Use explicit pending states and recovery logic; our plugin integration essay explores the event and outbox side of that problem.
Plan for deadlocks and uncertain outcomes
Transactions can acquire overlapping resources in different orders and deadlock. Keep them short, use a consistent acquisition order and index the predicates used to find rows. Do not hold a transaction open while waiting for a payment provider or sending email.
A deadlock victim needs an appropriate retry of the whole logical transaction, with a finite attempt limit and backoff. A lock-wait timeout and a lost connection do not necessarily have identical rollback semantics. Explicitly clean up the transaction according to the actual error and driver behaviour. MySQL documents deadlock handling as an application responsibility.
If the connection disappears during commit, the caller may not know whether the reservation committed. Retrying without an operation identity can duplicate it. Resolve the outcome through the idempotency record after reconnecting rather than guessing from the absence of a success response.
A cache is not the authority for scarce capacity
A cached availability count can help render a useful interface. The final claim must still enforce the authoritative invariant. A page saying “one place remaining” is information, not a reservation.
Do not implement a distributed lock as a cache read followed by a cache write. Even a backend with atomic acquisition needs expiry, ownership and release semantics. A lease that expires while its owner is still working can allow a second owner to proceed; a database transition still needs protection against stale workers.
Make the race reproducible
Use separate database connections or processes and a barrier that releases competing workers together. Start with one available slot. Record successful claims, failed claims, remaining capacity and committed booking rows. Assert the invariant rather than relying on the order in which console messages appear.
Also test process termination between the claim and booking insert, duplicate operation keys, concurrent cancellation and a late payment event after a hold expires. Artificial sleeps can demonstrate an unsafe interleaving, but a passing run without one does not prove the race is absent.
The design earns confidence when the database protects the rule and the tests can explain what happens at every uncertain boundary. Fast execution reduces the window in which a race is noticed. It does not remove the race.
Code examples illustrate the architecture and identify application-specific interfaces. Adapt them to your environment and test the failure paths before deployment. Sources are linked alongside the relevant discussion.