← Back to list

Why Booking Systems Are Harder Than They Look

Backend engineering, Django, Concurrency, Production systems

Abdulmalik Adebayo · 2026-05-25 21:43 · 0 claps · 4.6 min read
#python #django #backend-development #software-engineering #system-design-concepts
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Why Booking Systems Are Harder Than They Look

Backend engineering, Django, Concurrency, Production systems

Introduction

A booking system looks simple on the surface. A user picks a time slot and books it. That is it, right?

That is what I thought too until I started building one.

Under real traffic, things get complicated fast. Two users can click the same slot at the exact same millisecond. A slow network can cause the frontend to retry a request that already went through. Before you know it, the same slot has two confirmed bookings and your database is in an inconsistent state.

Building a scheduling API taught me more about backend engineering than any CRUD tutorial ever did.

Same slot collision

Same slot collision

The Double Booking Problem

Without any protection, here is exactly what happens when two users book the same slot at the same time:

Request A checks availability → slot is free
Request B checks availability → slot is free

Request A creates the booking
Request B creates the booking

Both requests passed the availability check. Both created a booking. The same 10:00 AM slot now has two confirmed reservations.

This is a race condition. Neither request did anything wrong individually. The problem is that they ran at the same time and the system had no way to coordinate between them. By the time Request B checked availability, Request A had not finished yet so the slot still looked free.

The result is an inconsistent database state. Two people receive confirmation emails for the same meeting. The host has no idea which one to honor.

Race condition without locking

Race condition without locking

What SELECT FOR UPDATE Actually Does

This is the real concurrency weapon.

Think of it like a bathroom lock. When someone walks in, they lock the door. The next person does not break in they wait outside until the door opens. That is exactly what SELECT FOR UPDATE does at the database level.

When you write this:

with transaction.atomic():
    bookings = Booking.objects.select_for_update().filter(
        event_type=event_type,
        start_time=requested_slot,
        status='confirmed'
    )

You are telling PostgreSQL: lock these rows until my transaction finishes. Nobody else touches them until I am done.

Request A enters first. It locks the relevant rows. Request B arrives and tries to lock the same rows. The database says wait, someone else is using this. Request B pauses completely. It does not check a stale value. It does not sneak through. It simply waits.

When Request A commits, the database releases the lock. Only then does Request B continue and by that point, the slot is already taken. Request B sees the conflict and returns a 409.

One important thing: select_for_update() is useless outside a transaction. Without transaction.atomic() wrapping it, the lock is released immediately and you get no protection at all.

Database Row Lock Queue

Database Row Lock Queue

Why My Concurrency Test Was Fake

When I first wrote my concurrency test, I was convinced it worked. Two threads, both trying to book the same slot. The lock held. Only one booking was created. Test passed.

But the test was lying to me.

The problem was thread scheduling. Without any coordination, the operating system decides when each thread runs. In practice, one thread almost always finishes before the other even starts. What I was actually testing looked like this:

Time    Thread A                Thread B
T1      starts                  —
T2      finishes booking        —
T3      —                       starts
T4      —                       sees conflict

The test passed. But I never tested concurrency. I tested sequential execution with extra steps.

The fix is threading.Barrier.

A Barrier is a synchronisation point. You tell it how many threads to expect, and it holds every thread at that point until all of them have arrived. Nobody moves until everyone is ready.

barrier = threading.Barrier(2)

def book(barrier):
    barrier.wait()  # both threads wait here
    create_booking()  # both released simultaneously

Now both threads hit the database at the exact same time. The race condition is real. That is the only way to meaningfully test locking behaviour.

“Nobody moves until everyone arrives.”

How Barrier creates real concurrency in tests

How Barrier creates real concurrency in tests

Idempotency Solves a Different Problem

SELECT FOR UPDATE handles concurrency. But there is a completely separate problem that locking cannot solve — network retries.

These are two different problems. Do not confuse them.

Here is the scenario:

  • User clicks “Book”
  • Server receives the request and creates the booking
  • Network dies before the response reaches the client
  • The client sees a timeout and thinks the request failed
  • So it retries

Without idempotency protection, the server has no memory of the first request. It treats the retry as a brand new booking and creates a duplicate. The user ends up with two confirmed bookings for the same slot from the same click.

Idempotency solves this by giving each request a unique identity.

The client sends a header with every request:

Idempotency-Key: abc123

The server stores the result of that request:

abc123 → booking_id=42

If the same request arrives again with the same key, the server does not create a new booking. It simply returns the existing one.

Same key. Same result. Every time.

Same Request Retries Multiple times

Same Request Retries Multiple times

The Biggest Backend Lesson

I used to think backend engineering was mostly CRUD endpoints and database models. Build the API, connect the database, return the response. That felt like the whole job.

Building a booking system from scratch changed that completely.

The moment I introduced real concurrent traffic, things I never thought about started breaking. I had to start thinking about problems I had never considered before:

  • Race conditions that only appear under simultaneous requests
  • Transaction boundaries that determine what succeeds or fails together
  • Retries that can silently create duplicate data
  • Invariants rules the system must never violate no matter what
  • Consistency under concurrent traffic that no unit test warned me about

None of that shows up in a CRUD tutorial.

A booking system is not just about storing appointments. It is about preserving correctness under real-world conditions where networks fail, users click twice, and two people always want the same slot.


메타데이터
post_id
762e2cdbb53b
slug
why-booking-systems-are-harder-than-they-look-762e2cdbb53b
url
https://medium.com/@adebayoabdulmalik12/why-booking-systems-are-harder-than-they-look-762e2cdbb53b
canonical_url
https://medium.com/@adebayoabdulmalik12/why-booking-systems-are-harder-than-they-look-762e2cdbb53b
author_url
https://medium.com/@adebayoabdulmalik12
status
ok
fetched_at
2026-06-09 14:34:10