0x55aa
← Back to Blog

Race Conditions: How a 0.001ms Gap Is Letting Your Users Buy Things You Don't Have 🏁

7 min read

Race Conditions: How a 0.001ms Gap Is Letting Your Users Buy Things You Don't Have 🏁

Picture this: you're launching a flash sale. One item left in stock. Three users hit "Buy Now" simultaneously. Your inventory check runs fine for all three. They all pay. You now owe three people a product you physically don't have.

Congrats. You just got race-conditioned. 🎉

In my experience building production e-commerce backends — including serverless stacks processing thousands of concurrent requests — race conditions are the vulnerability that nobody talks about until after the incident report is written.


What Even Is a Race Condition? 🤔

A race condition happens when two or more operations that should be sequential happen to overlap in time, and the outcome depends on who finishes first.

Your code assumes it's the only thing running. The internet disagrees.

The classic pattern looks like this:

1. Read a value  →  "Stock: 1"
2. Check: is it > 0? Yes!
3. ...0.002ms gap...
4. Decrement stock

If two requests both hit step 1 before either hits step 4, they both see "Stock: 1", both pass the check, and both decrement. You're now at -1 units.

This isn't a bug. It's a feature — for attackers. 😈


Real-World Scenarios That Should Terrify You 🎯

The Coupon Code Massacre

This one I've actually seen in security communities. A user finds a "use once" promo code. They write a simple script that fires 50 requests simultaneously. Each request reads "coupon used: false", each passes validation, and suddenly one promo code gets applied 47 times.

// The vulnerable flow (Don't do this!)
const coupon = await db.findCoupon(code);

if (coupon.used) {
  throw new Error('Already redeemed');
}

// 🚨 DANGER ZONE: another request can slip in here 🚨

await db.markCouponUsed(code);
await applyDiscount(userId, coupon.discount);

That gap between checking and updating? Attackers love that gap. They live in that gap.

Loyalty Points Double-Spend

User has 100 points. Minimum redemption is 100. They open two browser tabs and submit redemption simultaneously. Both requests see "balance: 100", both pass, both redeem. User walks away with 200 points worth of value from 100 points.

Banks call this a "double-spend attack." In web apps we call it a "critical production incident that requires an emergency all-hands."

The Inventory Oversell

My personal favourite (by which I mean my personal nightmare). Flash sale drops. 10 items available. 500 concurrent users. Your beautifully written inventory check runs simultaneously for all 500. They all see stock > 0. You sell 500 items. You have 10.


The Fix: Database-Level Atomicity 🔐

The golden rule: never read-then-write when you can write-with-a-condition.

Option 1: Atomic Updates (Your Best Friend)

-- Instead of: SELECT + check + UPDATE
-- Do this in one atomic operation:

UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = ? AND quantity > 0;

-- Check affected rows. 0 rows = out of stock.

If zero rows were affected, it means someone beat you to the last item. The check and the decrement happen atomically — no gap for anyone to slip through.

In Laravel:

// Bad: Read then write (race condition!)
$product = Product::find($id);
if ($product->quantity > 0) {
    $product->decrement('quantity');
}

// Good: Atomic conditional update
$updated = Product::where('id', $id)
    ->where('quantity', '>', 0)
    ->decrement('quantity');

if (!$updated) {
    throw new OutOfStockException();
}

One line. Zero gaps. Hackers cry.

Option 2: Database Locks (When You Need to Hold the Door)

For complex multi-step transactions, use pessimistic locking:

DB::transaction(function () use ($couponCode, $userId) {
    // Lock this rowno other transaction can touch it until we're done
    $coupon = Coupon::where('code', $couponCode)
        ->lockForUpdate()
        ->first();

    if ($coupon->used) {
        throw new CouponAlreadyUsedException();
    }

    $coupon->update(['used' => true, 'used_by' => $userId]);
    applyDiscount($userId, $coupon->discount);
});

lockForUpdate() tells the database: "This row is mine. Everyone else waits." Serial access, no race.

Option 3: Redis Distributed Locks (For the Microservices Crowd)

When your database isn't the bottleneck but your distributed system is:

const redis = require('redis');
const client = redis.createClient();

async function redeemCoupon(couponCode, userId) {
  const lockKey = `lock:coupon:${couponCode}`;
  const lockValue = `${userId}-${Date.now()}`;

  // SET NX = "Set if Not eXists" — atomic lock acquisition
  const acquired = await client.set(lockKey, lockValue, {
    NX: true,      // Only set if key doesn't exist
    EX: 10         // Auto-expire in 10s (safety net)
  });

  if (!acquired) {
    throw new Error('Coupon redemption already in progress');
  }

  try {
    await processRedemption(couponCode, userId);
  } finally {
    await client.del(lockKey); // Always release the lock
  }
}

Pro Tip: Always set an expiry on locks. If your process crashes mid-operation, a lock without expiry will block everyone forever. Ask me how I know. 🙃


Real Talk: How Attackers Exploit This 🕵️

In security communities, we often discuss how race conditions are a favourite tool for the "low-effort, high-reward" class of attacks. Here's why:

No special tools needed. A for loop with Promise.all() or a Python script with threading is enough to fire 50 concurrent requests. Burp Suite has a "Race Condition" testing module now. It's literally a button.

Hard to detect. Log entries look identical to legitimate requests. No suspicious payloads. No weird headers. Just... a lot of the same request at the same time.

Often overlooked in reviews. Code reviews catch SQL injection and XSS because we look for those. Race conditions require thinking in concurrent timelines, which is unnatural for humans who write sequential code.


Finding Race Conditions in Your Own App 🔍

Ask yourself these questions about any sensitive operation:

  1. Is there a "check then act" pattern? Read value → validate → update is the classic vulnerable pattern.
  2. What happens if this runs twice simultaneously? Walk through it manually. Run it in your head twice at the same time.
  3. Are you using SELECT + UPDATE in sequence? Red flag. Make it one atomic operation.
  4. Do you have unique constraints in the database? These are your last line of defence — use them.
  5. Is any counter, balance, or quantity involved? Those are prime targets.

Quick test: Open two browser tabs. Click the same "redeem" button in both within 100ms. If you get two success messages, you have a problem.


The Database Constraint Safety Net 🛡️

Even if your application code has a race condition, proper database constraints can prevent catastrophic damage:

-- Prevent inventory going negative
ALTER TABLE inventory ADD CONSTRAINT chk_quantity_non_negative
CHECK (quantity >= 0);

-- Unique constraint on coupon usage per user
ALTER TABLE coupon_usages ADD CONSTRAINT unique_coupon_user
UNIQUE (coupon_id, user_id);

These don't prevent race conditions — they just make sure the fallout is a database error instead of silent data corruption. Think of it as a crash net under your tightrope.


Pro Tip: Test with Artillery or k6 🎯

As someone passionate about security, I always run concurrency tests before shipping anything involving money or inventory:

# Fire 50 simultaneous requests to your coupon endpoint
# If 2+ succeed with the same coupon code, you have a race condition
echo '{"coupon": "SAVE50"}' | artillery quick \
  --count 50 --num 50 \
  -m POST \
  https://yourapi.com/redeem

If more than one request succeeds for a "use once" operation, you've found your race condition before the internet did.


TL;DR ⚡

Race conditions happen when concurrent requests share state without proper synchronisation. They're behind coupon abuse, inventory overselling, loyalty point fraud, and double-spend attacks.

The fixes:

  • Use atomic database operations (conditional UPDATE, check affected rows)
  • Use database transactions with locks (lockForUpdate() in Laravel)
  • Use distributed locks (Redis SET NX) for cross-service operations
  • Add database constraints as a safety net
  • Test concurrently before shipping anything involving money or counts

The most dangerous race conditions aren't in edge cases — they're in the happy path you tested sequentially and declared done.

Your code is sequential. Your users are not. Write code that assumes chaos. 🔐


Building production systems that handle concurrent load? Let's talk on LinkedIn or check out my projects on GitHub. In security communities, race conditions are increasingly showing up in bug bounty reports — worth adding to your review checklist.