Blog

The Concurrency Conundrum: A Story of Curiosity and Code

On Tuesday, Sep 23, 2025
post image

Here at Wawandco, we love to build things. But more than that, we love to understand how things work. This is a story about one of those times when a simple question led us down a rabbit hole of discovery, revealing valuable lessons about concurrency, atomicity, and the subtle art of managing shared resources. It all started with a small project called “Evento”.

The Challenge: A Simple Reservation System

Evento is a proof-of-concept for a reservation system. Imagine a popular event with a limited number of rooms available in different hotels. We expect many users trying to book rooms simultaneously. The core challenge is simple: we must not sell more rooms than we have.

This sounds straightforward, but when you have multiple servers and hundreds of concurrent users, things get complicated. This is a classic concurrency problem.

The Naive Approach: Hope for the Best

Our first attempt was a “naive” implementation. The logic was simple:

  1. Check if there are enough rooms available.
  2. If yes, create the reservation and update the number of available rooms.

Here’s what the code looked like in Go:


// server/naive.go

// Check if the number of rooms specified is available
query := `
    SELECT true
    FROM event_hotel_rooms
    WHERE
        event_id = $1
    AND
        hotel_id = $2
    AND
        contracted - (reserved + locked) >= $3
`
var available bool
err = conn.QueryRow(r.Context(), query, eventID, hotelID, rooms).Scan(&available)
if err != nil || !available {
    // Not enough rooms
    return
}

// Update the availability
query = `
    UPDATE event_hotel_rooms
    SET reserved = reserved + $1
    WHERE
        event_id = $2
    AND
        hotel_id = $3
`
_, err = conn.Exec(r.Context(), query, rooms, eventID, hotelID)

// ... insert reservation record

We quickly ran a simulation with 200 concurrent clients. The result? We overbooked!

Why? Because of a race condition. Imagine two users, Alice and Bob, trying to book the last available room at the same time.

  1. Alice’s request checks the availability. There is 1 room.
  2. Bob’s request checks the availability. There is 1 room.
  3. Alice’s request updates the database, setting reserved rooms to 1.
  4. Bob’s request updates the database, also setting reserved rooms to 1.

Both think they got the room, but we’ve sold the same room twice. The “check” and “update” operations are not a single, indivisible unit.

The “Atomic” Misconception

“Aha!” we thought. “We need a transaction!” A database transaction ensures that a series of operations either all succeed or all fail together. This property is called atomicity.

So, we wrapped our logic in a transaction:


// server/atomic.go
tx, err := conn.Begin(r.Context())
// ...

// check if quantity is available WITHOUT a FOR UPDATE lock
query := `
    SELECT true
    FROM event_hotel_rooms
    WHERE ...
`
// ...

// update rooms availability
query = `
    UPDATE event_hotel_rooms
    SET reserved = reserved + $1
    WHERE ...
`
// ...

tx.Commit(r.Context())

We ran the simulation again. The result? Still overbooking!

This was a key learning moment. Transactions alone do not guarantee isolation from other concurrent transactions. While our operations were now atomic (they would all complete or none would), two parallel transactions could still read the same initial data and create a race condition, just like before.

Pessimistic Locking: “Don’t Touch This!”

The problem is that other transactions can interfere. So, we decided to be pessimistic. Pessimistic locking assumes that conflicts are likely and prevents them by locking the data.

We modified our query to use SELECT ... FOR UPDATE. This tells the database to lock the selected rows until the transaction is complete. Any other transaction trying to read or update the same rows will have to wait.


// server/pessimistic.go
tx, err := conn.Begin(r.Context())
// ...

// check if quantity is available with a FOR UPDATE lock
query := `
    SELECT true
    FROM event_hotel_rooms
    WHERE
        event_id = $1
    AND
        hotel_id = $2
    AND
        contracted - (reserved + locked) >= $3
    FOR UPDATE
`
// ... (the rest is the same)

tx.Commit(r.Context())

Success! We ran the simulation, and not a single room was overbooked. Pessimistic locking solved the problem by serializing access to the resource. However, it has a downside: it can reduce concurrency, as other transactions have to wait for the lock to be released.

Optimistic Locking: “I’ll Check for Changes”

What if conflicts are rare? Pessimistic locking might be overkill. Optimistic locking assumes that conflicts are unlikely. Instead of locking, you check if the data has changed before you commit your transaction.

We added an updated_at timestamp to our event_hotel_rooms table. The logic is:

  1. Read the updated_at timestamp along with the availability.
  2. When updating, add a WHERE clause to check if the updated_at timestamp is the same as what we initially read.

If the timestamp is different, it means another transaction has modified the data. Our update will fail, and we can then retry the whole process.


// server/optimistic.go

// check if quantity is available and get current updated_at timestamp
query := `
    SELECT updated_at
    FROM event_hotel_rooms
    WHERE ...
`
var updatedAt time.Time
err = tx.QueryRow(r.Context(), query, eventID, hotelID, rooms).Scan(&updatedAt)
// ...

// update rooms availability and check timestamp
query = `
    UPDATE event_hotel_rooms
    SET reserved = reserved + $1, updated_at = NOW()
    WHERE
        event_id = $2
    AND
        hotel_id = $3
    AND
        updated_at = $4
`
res, err := tx.Exec(r.Context(), query, rooms, eventID, hotelID, updatedAt)

// Check if the update affected any rows
if res.RowsAffected() == 0 {
    // Conflict! Data was modified.
    return
}

// ...

This approach also worked perfectly. It can offer better performance in low-contention scenarios because it doesn’t block other transactions.

Lessons Learned

This journey through the evento repository was a fantastic reminder of some fundamental principles:

  • Concurrency is hard: What seems simple can have subtle bugs when multiple users are involved.
  • Atomicity is not enough: Database transactions are essential, but they don’t magically solve all concurrency issues.
  • Choose the right tool for the job: Both pessimistic and optimistic locking are valid strategies. The best choice depends on your specific use case and the likelihood of conflicts.

At Wawandco, we believe that curiosity is a powerful driver for learning. By building, testing, and breaking our own code, we gain a deeper understanding of the technologies we use every day. This little experiment with evento was a perfect example of that spirit in action.

You can find the full source code for the project on GitHub: https://github.com/wawandco/evento.

Further Reading

To dive deeper into the concepts discussed in this post, here are a few resources:

Share this post: