This is a series where I am starting to share the best practices for backend development, its architecture, and system design for high throughput across the system with minimum cost.
I have divided this into 5 mini projects where we can learn with the help of the Iterative Refinement process, which will help us to know which issues can be come in real life production applications.
First of all, we will start with the project which is database-driven and covers concepts like Concurrency, Race Conditions, Locks.
📚 Table of Contents
- Introduction
- Basic Setup for Learning
- Replacing Single Database Connections with a Pool
- Race Condition: Overselling Tickets
- Atomic Updates + Transactions
- What We Learned
- Where This Pattern Is Used in Production
- What's Next in This Backend Best Practices Series
Basic Setup for Learning:
Setting up the DB config & minimal API
Spin up a DB on your local computer with the help of Docker; I have used Postgres for this with Docker Compose.
Create the tables in the DB for movies and orders; movies must have the tickets available count, and the orders table is for saving ticket details. Each ticket has a maximum of 100 seats.
docker-compose.yml
services:
db:
image: postgres:alpine
container_name: alpine_postgres
restart: always
environment:
POSTGRES_USER : postgres
POSTGRES_PASSWORD: strongPass@123
POSTGRES_DB : my_db
ports:
- "5432:5432"
volumes:
- postgress_data:/var/lib/postgresql
volumes:
postgress_data:
Create a simple API
Create a simple API that will take input from the user for a ticket, which has a name, quantity of tickets, and movie ID for which it has to buy the tickets.
Add the logic: if
available_tickets - qty > 0, then
only the order should be created, and the quantity of seats should be reduced; otherwise, give the error: "tickets not available".
Load testing
Use the K6 library for load testing by sending 1,000 simultaneous requests to your API endpoint at the exact same moment.
Expected Result: You will experience a massive race condition. Because hundreds of requests read the database at the exact same millisecond, they all saw 100 tickets available. They all passed the check, and they all subtracted 1. When the dust settles, your database will show negative available tickets, and you will have hundreds of successful orders in your Orders table.
load-test.js
import http from 'k6/http';
import { check } from 'k6';
// 1. Configure the Load
export const options = {
// We want to simulate 1,000 users hitting the button at the exact same time
vus: 1000,
// We will sustain this pressure for 5 seconds
duration: '5s',
};
// 2. Define the User Action
export default function () {
const url = 'http://localhost:8080/book';
const payload = JSON.stringify({
movie_id:1,
name:"Ani",
qty:1
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
// Execute the POST request
const res = http.post(url, payload, params);
// Verify the server responded with a 200 OK
check(res, {
'is status 2xx': (r) => r.status >= 200 && r.status <= 299,
});
}
What actually happens!!!:
Ran an initial stress test with k6 blasting 1,000 Virtual Users (VUs) for 5 seconds.
checks_failed: 99.98% (159575 out of 159606)
Postgres working: conn busy
http: panic serving: watch already in progress
Why did this happen!?
Because a single, raw database connection was shared globally across the HTTP handler. Thousands of concurrent and simultaneous reads and writes were attempted on the exact same underlying TCP socket. The database driver flagged this concurrent socket access as conn busy.
What the Fix
- Migrated from a single connection to a connection pool
Transactions and Concurrency
Now re-ran the stress test with the connection pool config and got the new expected error; the HTTP layer was stable: 71,996 requests processed at ~14,266 req/s with 0% HTTP errors, but the ticket count ended at -770, and hundreds of invalid orders were created.
Why did this happen? Because hundreds of concurrent queries executed the SELECT query at the exact same microsecond before any single transaction had committed its update. Every query read count = 100, evaluated 100 > 0 as true in memory, and executed an UPDATE, completely stomping over concurrent writes. Demonstrates why application-layer conditional checks (if tickets > 0) cannot guarantee transactional isolation under concurrency.
To prevent data race conditions, add explicit transactions combined with atomic updates.
After this, update the current logic with the transactional combination and re-run the K6 stress test. It handled every request perfectly; throughput was ~7,176 requests/second, exactly 100 tickets were sold, and the remaining ticket count was locked at exactly 0. There were no deadlocks, no socket panics, and no data corruption.
This is how you build a concurrent ticket booking system or a high-update database like the BookMyShow application, the IRCTC train booking system, or a flight booking systemwith the help of Database Pooling, Transactions and Locks.
What We Learned
- Never share a single database connection across concurrent HTTP requests.
- Connection pooling solves socket-level concurrency issues.
- Application-layer
if (tickets > 0)checks are unsafe under heavy concurrency. - Atomic SQL updates inside a database transaction prevent overselling.
- PostgreSQL transactions and row locks guarantee consistency under high write throughput.
Where This Pattern Is Used in Production
This exact pattern is used in systems where inventory or seats are limited:
- BookMyShow movie ticket booking.
- IRCTC train reservations.
- Flight booking systems.
- Flash sale and limited-stock e-commerce platforms.
What's Next in This Backend Best Practices Series
In the next mini project, we'll move beyond database concurrency and build a high-throughput backend using Redis for distributed locking, idempotency, and request coordination under massive traffic spikes.


Top comments (0)