A hundred people tried to book seat 1 of the same event, and my booking API said yes to all of them.
This is SeatRush, a ticket reservation API I am building in Go with Gin. A seat is AVAILABLE, HELD or BOOKED, and the whole job of POST /reservations is to make sure a seat goes from AVAILABLE to HELD exactly once. It turned out that "exactly once" is the hard part.
What I wanted to understand
On a ticket site, a popular show opens and thousands of people click the same seat in the same second. The server has to pick one winner and tell everyone else no. I wanted to see, in my own code, what happens when nothing is stopping them.
What I wrote first
The reservation handler looked fine. Find the seat, check it is free, mark it held.
seatFound := false
seatHeld := false
for i := range seats {
if seats[i].ID == input.SeatID && seats[i].EventID == input.EventID {
seatFound = true
if seats[i].Status == StatusAvailable {
seats[i].Status = StatusHeld
seatHeld = true
}
break
}
}Read top to bottom by one person, it is correct. The problem is that it is never read by one person.
Reproducing it: 100 goroutines, one seat
Before touching the server, I wanted the bug on its own, in a test. One seat, 100 goroutines all trying to hold it. The time.Sleep sits between the check and the write, to make the gap wide enough to see.
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if seat.Status == StatusAvailable {
time.Sleep(time.Millisecond)
seat.Status = StatusHeld
success <- struct{}{}
}
}()
}
wg.Wait()
close(success)
successfulHolds := len(success)
if successfulHolds != 1 {
t.Fatalf("expected 1 successful hold, got %d", successfulHolds)
}Then I ran it with Go's race detector:
go test -race -run TestConcurrentReservationRace -v
Expected 1 successful hold, got 100. The race detector points at the exact lines: one goroutine writes the status (main_test.go:26) while another is still reading it (main_test.go:24).
Not two, not five. All 100 goroutines got the seat.
Why
This pattern has a name: check-then-act. The check (if Status == AVAILABLE) and the act (Status = HELD) are two separate steps, and nothing stops another goroutine from running in between them.
goroutine A: is seat free? -> yes
goroutine B: is seat free? -> yes (A has not written yet)
goroutine A: seat = HELD -> A gets the seat
goroutine B: seat = HELD -> B gets the same seatEvery goroutine read AVAILABLE before any of them had written HELD. The sleep only widened a gap that is always there. Without it the window is a few nanoseconds wide instead of a millisecond, and real traffic will still hit it.
The idea of the fix: a mutex
A sync.Mutex lets only one goroutine at a time into a block of code. Everyone else waits at Lock() until the holder calls Unlock(). If the check and the act both happen inside the lock, nobody can get in between them.
go func() {
mu.Lock()
defer mu.Unlock()
if seat.Status == StatusAvailable {
time.Sleep(time.Millisecond)
seat.Status = StatusHeld
success <- struct{}{}
}
}()
Same test, same sleep, with the lock around the check and the write. Successful holds: 1. PASS.
defer mu.Unlock() means the lock is released however the function exits, including an early return. Forgetting an unlock does not crash anything; it just makes every later request wait forever.
But look at where that lock is. It is inside the test. The test now proves that a mutex works. It says nothing about my server, which still had no lock at all.
Hitting the real server
So I started the API and fired 20 real HTTP requests at it, all at once, all for seat 1:
for i in {1..20}; do
curl -s -X POST http://localhost:8080/reservations \
-H "Content-Type: application/json" \
-d '{"user_id":1,"event_id":101,"seat_id":1}' &
done
waitThe & sends each curl to the background, so all 20 requests are in flight together instead of one after another.

Four reservations, IDs 501 to 504, all PENDING, all for seat 1. The other sixteen got "Seat unavailable".
Four different people now held the same seat.
Why does a plain HTTP handler race at all? Because Go's net/http server, which Gin runs on, handles every connection in its own goroutine. Twenty requests are twenty goroutines touching the same seats slice, which is exactly the test from before, just with real users instead of a for loop. It was four and not twenty only because HTTP requests do not arrive in the same nanosecond. A bigger crowd would get more doubles.
The real fix: the lock goes in the server
All the shared state (events, seats, reservations and the ID counters) moved into one App struct, with the mutex that guards it sitting right next to it:
type App struct {
events []Event
seats []Seat
reservations []Reservation
nextEventID uint
nextSeatID uint
nextReservationID uint
mu sync.RWMutex
}Holding a seat became a method that does the find, the check, the write and the new reservation ID, all under one lock:
func (app *App) holdSeat(input CreateReservationRequest) (bool, Reservation) {
app.mu.Lock()
defer app.mu.Unlock()
for i := range app.seats {
if app.seats[i].ID != input.SeatID || app.seats[i].EventID != input.EventID {
continue
}
if app.seats[i].Status != StatusAvailable {
return true, Reservation{}
}
app.seats[i].Status = StatusHeld
reservation := Reservation{
ID: app.nextReservationID,
UserID: input.UserID,
EventID: input.EventID,
SeatID: input.SeatID,
Status: ReservationStatusPending,
}
app.nextReservationID++
app.reservations = append(app.reservations, reservation)
return true, reservation
}
return false, Reservation{}
}The handler just calls it and turns the result into a status code: 201 Created for the one winner, 409 Conflict for everyone else.
seatFound, reservation := app.holdSeat(input)
seatHeld := reservation.ID != 0
if !seatFound {
c.JSON(http.StatusNotFound, gin.H{"error": "Seat not found"})
return
}
if !seatHeld {
c.JSON(http.StatusConflict, gin.H{"error": "Seat unavailable"})
return
}
c.JSON(http.StatusCreated, reservation)The ID counter needs the lock too. nextReservationID++ is itself a read followed by a write, so two goroutines could otherwise hand out the same reservation ID.
Why RWMutex and not Mutex
sync.RWMutex has two kinds of lock. Lock() is exclusive: one writer, nobody else. RLock() is shared: any number of readers together, but no writer while they read. A ticket site gets far more "show me the seats" than "book this seat", so the read routes do not have to queue behind each other:
app.mu.RLock()
eventSeats := []Seat{}
for _, seat := range app.seats {
if seat.EventID == uint(eventID) {
eventSeats = append(eventSeats, seat)
}
}
app.mu.RUnlock()
c.JSON(http.StatusOK, eventSeats)The reads copy what they need while holding the lock, release it, and only then write the response. A slow client should not keep every booking waiting.
The bug I almost shipped
In between, I had a version where holdSeat was a plain function, and the state was local variables captured by the route closures:
seatFound, reservation := holdSeat(&mu, seats, &reservations, &nextReservationID, input)holdSeat took the lock as its first line, so it looked protected. But seats is passed by value, which means the slice header (pointer, length, capacity) is read by the handler before holdSeat ever reaches Lock(). Meanwhile, POST /events/:id/seats does seats = append(seats, newSeat) under the lock.
So reading the slice was still unlocked. Running the server with -race while adding seats and booking at the same time flagged that exact line. Worse than the warning: if append had just moved the slice to a new array, holdSeat would mark an old copy as HELD, and nobody else would ever see it.
Moving the state into App fixed this as a side effect. Now app.seats is only ever read inside holdSeat, after the lock is taken. A lock only protects what you touch after you take it.
Proving it over HTTP
The last test does not lock anything itself. It builds a real App, gets the real Gin router, and sends 100 concurrent requests through router.ServeHTTP using httptest, with no port and no running server:
router := app.setupRouter()
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req, _ := http.NewRequest("POST", "/reservations", bytes.NewBuffer(bodyBytes))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
mu.Lock()
if w.Code == http.StatusCreated {
createdCount++
} else if w.Code == http.StatusConflict {
conflictCount++
} else {
t.Errorf("Ajeeb status code aaya: %d", w.Code)
}
mu.Unlock()
}()
}
wg.Wait()
if createdCount != 1 {
t.Fatalf("Expected exactly 1 success, got %d", createdCount)
}
if conflictCount != 99 {
t.Fatalf("Expected exactly 99 conflicts, got %d", conflictCount)
}Even the test's own counters are shared between 100 goroutines, so they get their own small mutex. Without it, -race would flag the test instead of the server.

go test -race -v: every route is registered on (*App).setupRouter, so this is the real handler under test. Exactly one "Seat held true".

Created: 1, Conflicts: 99. PASS, with the race detector on.
One seat, a hundred buyers, one booking.
What I am taking away
- Check-then-act is two steps. If something else can run between them, it eventually will. Both steps go inside the same lock.
- Every HTTP request is a goroutine. Any state shared between handlers is shared between goroutines, whether or not you ever typed
go. - Lock the state, not the test. A mutex in the test proves the idea. Only a lock in the server code protects users.
- Read under the lock too. Writes are not the only problem. Reading a slice header outside the lock is a race as well.
- Run
go test -race. It found every one of these bugs. Turning it on costs one flag.
A mutex is the right tool while SeatRush keeps everything in memory, in one process. Once there are two servers, or a database, this lock stops being enough. That is what I am building next: making the database refuse the second booking.