How to Build an Airport
26th July, 2026

What is the worst thing that can happen at an airport?
Not the line. Not the security. The worst thing that can happen is you find out that someone else also has a ticket to your seat because the website allowed double booking.
Here's what happened.
const { status } = await getSeat(code);
if (status === STATUS.AVAILABLE) {
await bookSeat(code);
} else {
// Handle error
}- You're on the website, choose the seat and click "Book".
- The website checks the status, and it's available
- Someone else viewing the same seat also clicks on "Book".
- The website checks the status for them, also available.
- The website books your seat successfully.
- The website books their seat successfully too.
And now, you both have the same seat.
How did this happen?
Checking the seat and booking it were two separate operations. There was a tiny gap between them, and the other request slipped through it.
If the whole website ran on one server, we could use a regular in-memory lock. It probably doesn't. Once we have several servers taking bookings, a lock on one server means nothing to the others.
We need one lock they can all see: a distributed lock.
Redis
Redis is a key-value database that keeps its data in memory, so reads and writes are very fast. We can use it as the place where all of our booking servers meet.
For seat 12A, we create a key called lock:seat:12A. Whichever server creates it first gets to book the seat. If the key is already there, the server knows somebody else beat it to the lock and stops.
You don't have to use Redis for this. Database row locks, etcd and ZooKeeper can all solve versions of the same problem. Redis is a common choice because the basic version takes one command and is easy to understand.
The data lives with the compute
Look back at the original code. We fetched AVAILABLE from the database, brought it over to our server, checked it and then sent another request to mark it as BOOKED. That is a lot of time for another request to show up.
We could make the same mistake with Redis:
if (!(await redis.exists("lock:seat:12A"))) {
await redis.set("lock:seat:12A", requestId);
}Now we have another check followed by another write. We moved the race condition, but we didn't fix it.
Instead, Redis lets us send one command that says, "create this key only if it doesn't exist." The option for that is NX.
Redis checks the key and writes it before moving on to another command. The data stays in Redis and the decision happens there too. One server gets the key. The other one doesn't. There is no gap in the middle for a second request to sneak through.
Redis can do more involved checks with transactions and Lua scripts, but NX is enough to acquire our lock.
The commands
Here is the command that tries to lock seat 12A:
SET lock:seat:12A request-a NX PX 10000Read it from left to right. We set lock:seat:12A to request-a, but only if the key does not exist (NX). Redis should delete it after 10,000 milliseconds (PX 10000).
request-a should be a new, random value for every attempt. Think of it as the name written on the lock. Redis returns OK if we got it and nil if somebody else already has it. That return value, not a separate GET, tells us whether we can continue.
Why the 10-second timeout?
A server can crash halfway through a booking. If that happens, it never gets a chance to clean up its lock. The timeout means Redis will eventually clean it up for us instead of leaving the seat stuck forever. To see how much time a lock has left, use:
PTTL lock:seat:12AIf the booking finishes normally, we don't need to sit around for those 10 seconds. We can delete the lock right away.
There is one catch. We shouldn't blindly run DEL. Imagine request A freezes, its lock expires and request B gets a new lock. If A wakes up and deletes the key, it has just deleted B's lock.
Before deleting anything, we check that the value is still request-a. The check and delete need to happen together, which is a small job for a Lua script:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0We run it with the lock key and the same random value we used when we acquired it:
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0" 1 lock:seat:12A request-aSo locks disappear in one of two ways. Redis removes them after the timeout, or the server that owns the lock removes them when it is done.
Ten seconds is just an example. The timeout has to be longer than a normal booking, otherwise the lock can expire while we are still using it. Longer jobs usually renew the lock as they work.
And the bookings table should still have a unique constraint on the seat. Locks reduce contention and let us give the user a sensible response, but the database is where we make double booking impossible.
Where else are distributed locks used?
Seats are only one example. The same idea comes up when we need to:
- run a scheduled job once, even though several servers are online
- stop two workers from charging the same payment or processing the same order
- reserve limited stock while an order is being placed
- run a database migration from one server during a deployment
- rebuild a cache or generate an expensive report once
- choose one server to act as the leader for a job
In each case, several machines are ready to do the work. The lock decides which one actually gets to.