DEV Community

Cover image for The Hidden Race Condition Inside firstOrCreate()
Elias Alrgeai
Elias Alrgeai

Posted on Originally published at Medium

The Hidden Race Condition Inside firstOrCreate()

A user accidentally double clicks 'add to cart' before the button disables, firing 2 requests to your server. The requests arrive within milliseconds of each other, both calling Cart::firstOrCreate(['user_id' => $userId]) for the same customer.

Both requests check to see if a cart exists, and since neither one has created it yet, neither finds a cart, and both requests proceed to create one, resulting in two for the same user.

This is a massive deal, since it could result in breaking the user experience or even double charging.

How firstOrCreate() Can Create a Race Condition

firstOrCreate() seems like an atomic process, but it isn't. Behind the scenes, calling firstOrCreate() runs two separate queries, with a gap between them:

SELECT * FROM carts WHERE user_id = ? LIMIT 1;
-- if nothing found:
INSERT INTO carts (user_id) VALUES (?);
Enter fullscreen mode Exit fullscreen mode

Here is how that gap causes a race condition:

Let's say Request A arrives milliseconds before Request B, and runs the SELECT query first. Request A finds nothing that matches the SELECT. Right before Request A fires its INSERT query, Request B runs its SELECT query, finding nothing either. Now, both requests send an INSERT query, creating duplicate carts.

The Naive Approach

A common assumption is that wrapping firstOrCreate() inside of DB::transaction() makes it atomic:

DB::transaction(function () use ($userId) {
    return Cart::firstOrCreate(['user_id' => $userId]);
});
Enter fullscreen mode Exit fullscreen mode

However, this doesn't actually fix anything. DB::transaction() only creates an atomic environment for requests nested inside of it, and has zero control over a completely separate request out of its scope. Request A and Request B don't know each other, and therefore they can't be wrapped together in a transaction.

The Correct Fix

The correct fix to this issue isn't relying on more application code. Instead, it's enforcing a database-level unique constraint on the column being checked:

$table->unique('user_id');
Enter fullscreen mode Exit fullscreen mode

This way, if two requests both attempt the same INSERT query, the database will reject the duplicate creation attempt, making it impossible for more than one of the queries to succeed. To prevent an unhandled exception, the database rejection must be managed:

use Illuminate\Database\QueryException;

try {
    $cart = Cart::create(['user_id' => $userId]);
} catch (QueryException $e) {
    $cart = Cart::where('user_id', $userId)->first();
}
Enter fullscreen mode Exit fullscreen mode

Implementing a unique constraint makes it structurally impossible for duplicates to occur, since it is forced at the database level where the data exists, not on the application side.

Summary

firstOrCreate() seems like a single operation, but behind the scenes, it's a SELECT query followed by an INSERT query, with a gap between them. If two identical requests arrive within milliseconds of each other, there is a genuine chance that they both fall through, causing duplicate rows in the database.

Wrapping firstOrCreate() inside of the DB::transaction() method doesn't actually fix anything, since DB::transaction() only creates an atomic environment for requests nested inside of it, not completely separate requests it doesn't even know exist.

The correct fix is enforcing a unique constraint at the database level, which makes it structurally impossible for duplicates to happen no matter what.

Top comments (0)