How I Fixed a Race Condition in a Live Seat Booking System (And Lost Sleep Over It)

Published: (March 28, 2026 at 08:28 PM EDT)
2 min read
Source: Dev.to

Source: Dev.to

The Problem

Two users, one seat – total chaos.
Both User A and User B queried the seat at nearly the same millisecond, saw it as available, and proceeded to book. The database was updated twice, resulting in the same seat having two owners.

Failed Approaches

Simple read‑then‑write check

I first tried a straightforward if check: read the seat status, then write the new booking. This broke immediately under any real concurrency because the read‑then‑write sequence is not atomic. The tiny window between the two operations is exactly where race conditions thrive.

Client‑side flag

Next I added a client‑side flag to indicate a lock. This also failed—Socket.io events can arrive out of order, and the UI state is not a reliable source of truth.

The Solution

Collapse the read and the write into a single atomic MongoDB operation and pair it with a Socket.io broadcast so every connected client sees the lock instantly.

// reserveSeat.js
async function reserveSeat(tripId, seatNo, userId) {
  // Atomically find the seat if it is still free and mark it as taken
  const seat = await Seats.findOneAndUpdate(
    { tripId, seatNo, taken: false },          // query condition (the guard)
    { $set: { taken: true, userId } },        // update (atomic at document level)
    { returnDocument: 'after' }              // return the updated document
  );

  if (!seat) {
    throw new Error("Seat already taken!");
  }

  // Broadcast the lock to all travelers
  io.emit('seatLocked', {
    seatNo,
    userId,
    message: `Seat ${seatNo} secured for User ${userId}`
  });
}

Key Steps

  1. The query condition is the guard – only match seats that are still free.
  2. findOneAndUpdate is atomic at the document level – the read and write happen in a single operation.
  3. The null check is your error boundary – if no document was returned, the seat was already taken.
  4. Socket.io broadcasts the lock immediately – all clients receive the update in real time.

The Biggest Lesson

The fix isn’t just about MongoDB; it’s a mindset shift: never trust a read before a write in a concurrent system. Push your guard logic into the database operation itself, where it can be atomic.

Your Turn

Have you run into race conditions in your own projects? I’d love to hear how you handled them.

0 views
Back to Blog

Related posts

Read more »