If you're joining fresh: we're designing a system where a tiny, fixed number of seats (think 500) go up for booking at one exact timestamp, and roughly 2 million people try to book them in the same second. In Part 1, we established the core truth that makes this problem interesting: it's not a throughput problem, it's a contention problem. You cannot scale your way into more seats. We also walked through three naive approaches (direct DB locking, no admission control, optimistic retries) and watched each one collapse under real concurrency.
This part is where we build the thing that actually survives launch day.
Before any code: the single biggest lever in this entire design is controlling when and how requests reach your contended resource. Everything else, Redis, Kafka, your database schema, is secondary to getting this part right. If you let 2 million requests hit your critical section at once, no amount of clever locking saves you. If you structure the traffic first, a surprisingly modest amount of infrastructure handles the rest comfortably.
Keep that in mind as we go section by section.
Before the booking window even opens, millions of people are refreshing the page, checking train schedules, looking at seat maps, checking prices. None of that touches your core booking logic, and none of it should touch your origin servers either.
This layer's entire job is to make sure the "just looking" traffic never gets anywhere near the part of the system that has to be perfectly correct.
This is the layer most naive designs skip entirely, and it's arguably the most important one.
Before the exact opening timestamp, users who load the booking page are placed into a virtual queue rather than given direct access to the booking API. Think of it as a bouncer at a club door: everyone's in line, in order, and you let people in at a rate the venue can actually handle.
interface QueueEntry {
userId: string;
queuedAt: number;
queuePosition: number;
admissionToken: string | null;
}
async function enterWaitingRoom(userId: string): Promise<QueueEntry> {
const position = await queueStore.enqueue(userId);
return {
userId,
queuedAt: Date.now(),
queuePosition: position,
admissionToken: null,
};
}
At exactly 10:00:00 AM (or whatever your opening timestamp is), the system starts admitting users out of this queue into the actual booking flow, at a controlled rate.
async function admitNextBatch(batchSize: number): Promise<string[]> {
const nextUsers = await queueStore.dequeueBatch(batchSize);
const tokens = nextUsers.map(userId => ({
userId,
token: generateAdmissionToken(userId),
}));
await Promise.all(tokens.map(t => cache.set(`admission:${t.userId}`, t.token, { ttl: 300 })));
return tokens.map(t => t.token);
}
The batch size here is tuned to whatever your app tier can actually process without falling over, not to how many people are waiting. This single design choice converts an uncontrolled stampede into an orderly, rate-limited stream, and it's the reason ticketing platforms and exam portals that do this well feel calm even during a rush, while the ones that skip it fall over immediately.

Once a user is admitted, they hit the actual booking attempt. This is where atomicity has to be airtight, and this is why Redis, not the primary database, sits at the center of this layer.
Before the window opens, seat inventory is pre-loaded into Redis as simple counters, one per seat class per train.
seat_count:{trainId}:{classCode} = 500
When an admitted user tries to book, the check-and-decrement has to happen as a single atomic operation, otherwise two requests could both read "1 seat left" and both proceed. Redis being single-threaded per operation makes this straightforward with a Lua script, since Redis guarantees the entire script executes without any other command interleaving:
-- KEYS[1] = seat_count key
-- ARGV[1] = user id
-- ARGV[2] = idempotency key
-- ARGV[3] = lock TTL in seconds
local idempotencyKey = "idem:" .. ARGV[2]
if redis.call("EXISTS", idempotencyKey) == 1 then
return {err = "DUPLICATE_REQUEST"}
end
local available = tonumber(redis.call("GET", KEYS[1]))
if available <= 0 then
return {err = "SOLD_OUT"}
end
redis.call("DECR", KEYS[1])
redis.call("SETEX", idempotencyKey, tonumber(ARGV[3]), "1")
redis.call("SETEX", "lock:" .. ARGV[1] .. ":" .. KEYS[1], tonumber(ARGV[3]), "reserved")
return {ok = "RESERVED"}
Two things worth calling out here, because they're easy to miss on a first read:
Here's a subtlety worth sitting with: Redis granting a reservation is not the same thing as a booking being durable. Redis is fast, but it's not where you want your permanent financial and booking records living. That's still your relational database's job.
So the moment Redis grants a reservation, an event gets published to Kafka rather than writing directly to the database in the same request:
interface SeatReservedEvent {
eventType: "SEAT_RESERVED";
userId: string;
trainId: string;
classCode: string;
reservedAt: number;
idempotencyKey: string;
}
async function publishReservation(event: SeatReservedEvent): Promise<void> {
await kafkaProducer.send({
topic: "seat-reservations",
// partition by trainId so all events for the same train
// are processed in order, by the same consumer instance
key: event.trainId,
value: JSON.stringify(event),
});
}
The partition key matters more than it looks. Partitioning by trainId guarantees that every event for a given train is handled sequentially by the same consumer, which keeps your database writes for that train's seats correctly ordered. It also isolates load: a wildly popular route doesn't slow down bookings for a quiet one, because they land on different partitions entirely.
A consumer service reads these events and writes the actual booking record to the database, marking it as PENDING_PAYMENT. This is your durable source of truth from this point forward, Redis was just the fast gatekeeper that made the decision instantly; the database is what survives a restart, an audit, or a dispute months later.
The user now has a soft-held seat and proceeds to payment, a separate service with its own latency and failure characteristics that should never be allowed to block the reservation logic above it.
PAYMENT_CONFIRMED event fires, the consumer updates the database row to CONFIRMED, and the Redis lock is converted from a TTL-based soft lock into a permanent "sold" marker (no more expiry).PAYMENT_FAILED event fires (or the TTL simply expires with no confirmation), the database row is marked EXPIRED or CANCELLED, and the Redis seat counter is incremented back, releasing that seat back into the pool for the next admitted user.
Stack it all together and the flow looks like this: static content and rough seat counts served from the edge, users held in an ordered waiting room until the exact opening moment, admission trickled in at a rate your backend can actually handle, atomic Redis operations making the instant reserve-or-reject decision, Kafka carrying that decision to your durable database without blocking the hot path, and payment confirmation closing the loop with either a permanent booking or a released seat.
This handles the vast majority of the load correctly. But there are sharp edges we've only mentioned in passing so far: what happens if a Redis node fails mid-reservation, how do you handle a flood of duplicate retries from flaky mobile connections, and what does reconciliation actually look like when things go wrong at 3am with no one watching. That's exactly where Part 3 picks up.
Subscribe to get posts like this straight to your inbox - no noise, just quality content.