Search

    Lolo Cab: shared-ride seats are a counter, and counters race

    Backend notes from Lolo Cab — why check-then-decrement seat counts break under concurrent bookings, and the atomic-update fix.

    4 min read

    Shared rides on Lolo Cab sell seats, not a whole car. One ride document holds a seat count; every booking request for that ride has to agree on how many are left. That agreement is the whole problem — this log is about what happens when two bookings land at the same moment.

    Seats are a counter, and counters race

    A shared ride starts with totalSeats and an availableSeats count that goes down as people book and up when they cancel. Simple enough as a single request. Not simple once you admit two vendor apps can hit “book this ride” in the same tens of milliseconds — a common case, not an edge case, right when a popular route fills up.

    If the last seat is available and two bookings for it arrive together, only one should win. Anything else is an overbooked ride and a driver showing up short a seat.

    The naive pattern: check, then trust yourself

    The obvious implementation reads the ride, checks the count in application code, decrements it, and saves:

    // illustrative — read-check-write, not atomic
    const ride = await Ride.findOne({ _id: rideId });
    
    if (ride.availableSeats < seatsRequested) {
      throw new Error('Not enough seats available');
    }
    
    ride.availableSeats -= seatsRequested;
    await ride.save();
    
    await Booking.create({ rideId, seatsRequested /* ... */ });

    This passes every manual test. It fails under load in a specific way: two requests both findOne the same document, both see availableSeats: 1, both pass the check, both decrement to 0 in memory, both save(). Mongoose’s version key does not save you here — by default it only guards array mutations, not a plain number field, unless the schema opts into optimisticConcurrency. Nothing in this path is atomic, so nothing stops the interleave.

    Fix: never let the application hold “is there a seat” and “take the seat” as two separate steps against a document already sitting in memory.

    Push the condition into the write

    The fix is to make the availability check part of the same atomic operation as the decrement, so the database — not the app — decides:

    // illustrative — the guard lives in the query filter, not in JS
    const ride = await Ride.findOneAndUpdate(
      { _id: rideId, availableSeats: { $gte: seatsRequested } },
      { $inc: { availableSeats: -seatsRequested } },
      { new: true },
    );
    
    if (!ride) {
      throw new Error('Not enough seats available');
    }
    
    await Booking.create({ rideId, seatsRequested /* ... */ });

    If the filter’s availableSeats condition doesn’t match at the moment MongoDB applies the update, the update simply matches nothing and ride comes back null. Two concurrent requests for the last seat can’t both succeed — the second one’s filter fails to match once the first one’s $inc has landed, full stop. No lock, no transaction needed for this part, just an atomic compare-and-decrement.

    Fix: the query filter is the concurrency control. read → check → write in application code is never safe against a second request; findOneAndUpdate with the condition in the filter is.

    Same bug in reverse: releasing seats

    Cancellations run the same pattern backwards — read the ride, add seats back in memory, save. It has the identical race, just in the direction of leaking capacity instead of overselling it: two cancellations can both read the same stale count and only one increment actually lands, so a ride shows fewer available seats than it should.

    Fix: cancellation gets the same atomic $inc treatment as booking, capped with $min against totalSeats so a stray double-release can’t push the count above capacity.

    Takeaways

    1. A seat count is a shared resource the moment more than one client can act on it — treat it as concurrent from day one, not after the first overbooking report.
    2. Read-check-write against an in-memory document is never safe under concurrent requests, no matter how careful the in-between logic looks.
    3. Put the condition in the atomic operation’s filter (findOneAndUpdate + $inc), not in an if statement between two separate calls.
    4. Apply the fix symmetrically — reserving and releasing are the same race in opposite directions.

    More Lolo notes later if we cover payment webhooks. The private setup guides stay in project notes — this log is the lessons, not the inventory.

    Fawad Naeem

    Fawad Naeem

    Full-stack developer in Lahore building React Native apps, Astro sites, and AWS-backed products. This log is where the shipping notes live.

    View portfolio ↗

    Working on something?

    If you want help shipping a mobile or web product, email me — or see past work on the portfolio.

    Occasional shipping notes

    No spam — just new build logs when they ship. Form is ready; provider wiring comes later.