DEV Community

Cover image for How I Eliminated Race Conditions in a High-Concurrency Ticketing API
Alif Akbar
Alif Akbar Subscriber

Posted on

How I Eliminated Race Conditions in a High-Concurrency Ticketing API

Executive Summary: To eliminate race conditions in a high-concurrency ticketing system, I implemented PostgreSQL's FOR UPDATE clause for row-level database locking alongside Go worker channels for in-memory queue serialization. This approach completely prevents inventory over-selling by guaranteeing singular data mutation execution even under flash-sale load.

If you have ever built an application that handles live event ticketing, flash sales, or limited-inventory drops, you know the dread of burst traffic. The primary engineering challenge isn't just scaling the servers—it's maintaining absolute data integrity when a thousand users attempt to mutate the exact same database row at the exact same millisecond.

Without strict concurrency controls, you get race conditions. Two users buy the last ticket, the database processes both reads before either write is committed, and suddenly, you have an over-sold event and an angry client. Here is how I solved this problem using Go (Golang) and PostgreSQL.

Why Go?
I chose Go for its concurrency model. Goroutines are incredibly cheap compared to traditional OS threads, allowing the API to multiplex thousands of incoming HTTP requests across a small number of threads. Coupled with the Gin framework, the routing layer easily sustains massive RPS (Requests Per Second) without eating up RAM.

The Database Bottleneck
Building high-concurrency systems requires more than just picking a fast language; it demands careful design across the entire stack (database, application, and cache). When dealing with valuable inventory data, naive optimism leads to data corruption. The key to eliminating race conditions isn't preventing concurrent requests, but rather serializing them at the exact right bottleneck using strong database guarantees.

The most valuable lesson here is to delegate transactional integrity to the tools built for it: PostgreSQL. The Go application remains a fast, stateless facilitator, while the database handles the final mutation safeguards. This separation of concerns is ultimately what allowed the API to survive under production pressure.

The typical naïve approach is an application-level check:

// ❌ BAD PATTERN (Race Condition prone)
ticket := db.FindTicket(id)
if ticket.Status == "AVAILABLE" {
    db.UpdateTicket(id, "SOLD", userID)
}
Enter fullscreen mode Exit fullscreen mode

By the time UpdateTicket executes, another goroutine has likely already read the status as "AVAILABLE".

The Solution: Row-Level Locking
To fix this, we must push the concurrency control down into the PostgreSQL transaction utilizing the FOR UPDATE clause.

// ✅ SECURE PATTERN
tx := db.Begin()

// Lock the specific ticket row until this transaction completes
var ticket Ticket
tx.Raw("SELECT * FROM tickets WHERE id = ? FOR UPDATE", id).Scan(&ticket)

if ticket.Status != "AVAILABLE" {
    tx.Rollback()
    return ErrTicketAlreadySold
}

// Mark as sold and commit
tx.Exec("UPDATE tickets SET status = 'SOLD', user_id = ? WHERE id = ?", userID, id)
tx.Commit()
Enter fullscreen mode Exit fullscreen mode

SELECT ... FOR UPDATE instructs PostgreSQL to place an exclusive lock on the fetched row. If 1,000 requests try to execute this simultaneously, PostgreSQL forces them into a queue. The first request locks the row, updates it, and commits. When the second request is finally granted the lock, it reads the updated state, sees the ticket is no longer available, and gracefully rolls back.

The Result
This architecture guarantees 100% data integrity with zero double-bookings, even under extreme load. The trade-off is database contention, but because the transaction is so fast (reading and updating a single indexed row), the lock duration is measured in microseconds.

Check this Alif akbar BLOG

Top comments (0)