intermediate
shop-rentals
public-booking
capacity-holds

Capacity Holds & Double-Booking Protection

How the 15-minute hold model prevents two customers from booking the last bike at the same time

Levy Fleets TeamMay 7, 20264 min read

When two customers open the public booking page at the same moment for your last available bike, only one can win. Capacity holds make sure the right one wins — without surprise overbooking — and gives the other a fast, clear "out of stock" message.

The problem holds solve

Without holds, a race condition is easy:

  1. Customer A loads the page at 9:00:00
  2. Customer B loads the page at 9:00:01
  3. Both see "1 bike available"
  4. Both fill in their details
  5. Both submit at 9:00:30

Without coordination, both bookings succeed and you've sold the same bike twice.

How holds work

When a customer reaches the "Submit" step on the public page, the backend creates a 15-minute hold in the reservation_holds table. The hold contains:

  • vehicle_model_id, quantity, pickup_at, return_at
  • pickup_location_id
  • addon_holds (any add-ons being held)
  • session_token — a unique token returned to the customer's browser
  • expires_at = now() + 15 minutes

From that moment, the availability check subtracts the held quantity from displayed availability. So Customer B, refreshing the page, sees "0 available."

What happens to the hold

Three possible outcomes:

  1. Customer A finishes and confirms — the create-booking endpoint consumes the hold (sets consumed_at = now()) and creates the actual reservation. Customer A wins, Customer B sees "0 available" permanently for that window.
  2. Customer A abandons — they close the tab without finishing. The hold expires after 15 minutes (expires_at < now()). Availability re-emerges; Customer B can refresh and try again.
  3. Customer A gets an error and retries — the same session token is preserved across retries, so they don't re-create the hold and double-block themselves.

The 15-minute window

15 minutes is the default expiration. It's a tradeoff:

  • Too short (5 min) → frustrated customers who took 6 minutes to fill the form lose their hold
  • Too long (60 min) → bikes locked up by abandoned tabs, hurting legitimate bookings

15 minutes balances: typical customer takes 2-5 minutes; abandoned tabs free up within a quarter hour.

The duration isn't currently operator-configurable. Contact support if you want to override.

What customers experience

Holds are invisible to customers. They see:

  • Real-time availability that reflects current bookings AND active holds
  • A green "0 available" warning if holds + bookings have consumed capacity
  • A successful booking confirmation if their hold consummated

They don't see the hold token or the expiration timer; everything is managed server-side.

Operator visibility

Operators can see active holds via the database:

SELECT
  rh.id,
  rh.vehicle_model_id,
  vm.name AS model_name,
  rh.quantity,
  rh.pickup_at,
  rh.return_at,
  rh.customer_email,
  rh.customer_name,
  rh.expires_at
FROM reservation_holds rh
LEFT JOIN vehicle_models vm ON vm.id = rh.vehicle_model_id
WHERE rh.subaccount_id = '<your-subaccount-id>'
  AND rh.consumed_at IS NULL
  AND rh.expires_at > now()
ORDER BY rh.created_at DESC;

Useful when investigating "why does the public page say no availability?" — abandoned holds might be the answer until they expire.

We don't yet have a dashboard UI for active holds. Likely a fast-follow.

Manually clearing holds

Rare. If you need to release a stuck hold immediately (operator error, testing), delete the row:

DELETE FROM reservation_holds
WHERE id = '<hold-id>';

This frees up the capacity instantly. Don't do this for legitimate customer-in-progress sessions — they'll get a confusing error on submit.

Holds and add-ons

The addon_holds JSON column on reservation_holds tracks which bookable add-ons (helmets, locks) are being held. The availability check for add-ons subtracts these too — so a customer in checkout with 2 helmets pending consumes 2 from the visible add-on availability of other concurrent customers.

If the customer abandons without consuming, the add-on inventory frees up when the hold expires.

Race conditions still possible

Holds prevent the most common race, but two truly simultaneous attempts within the same millisecond can both succeed if they hit different backend instances. Stripe's idempotency on the actual charge prevents double-billing, but the booking-creation race is theoretically open.

In practice, this is exceptionally rare — and if it happens, you have two reservations for one bike and need to manually cancel one. We're considering a database-level lock on the inventory cap to fully close this gap.

Cleanup

Expired and consumed holds aren't auto-purged today. They accumulate in the database. Run a periodic cleanup:

DELETE FROM reservation_holds
WHERE consumed_at IS NOT NULL OR expires_at < now() - interval '7 days';

Or schedule via your existing maintenance cron. Hold rows are small (few KB each) so this is mostly housekeeping, not a performance concern.

Summary

Capacity holds are why your public booking page reliably shows accurate real-time availability under load. They cost nothing to operate, are invisible to customers, and only briefly tie up inventory during active checkouts.