Design ticketing and booking

How do you sell 500 thalis to 20,000 people fairly?

About 55 minutes · open lesson · System design

Why did the Diwali special sell out twice?

Lesson 2.3 stopped a thali being sold twice with one conditional SQL update. This Diwali the kitchen offers 500 festival thalis, on sale at 11:00 for lunch, announced by SMS the evening before.

At 11:00, about 20,000 phones tap Buy within two seconds.

That update still does its job, so no thali is sold twice. Almost everything else about the sale goes wrong.

Most people watch a spinner for half a minute, then see “Something went wrong”. Within forty seconds every tap has its answer, and the answer is “Sold out”. A customer ordering an ordinary lunch at 11:00:05 cannot check out at all: every database connection is busy telling strangers that the thali is gone.

The kitchen screen shows 500 orders by two seconds past eleven. Count the payments and only 376 are paid. The other 124 thalis were claimed by people who closed the app at the payment page, or whose request timed out after it had already taken a box, so they never saw a payment page at all. Those 124 boxes stay marked as taken, and nobody is going to pay for them.

At three o’clock the accountant releases them by hand. They sell out again in under a minute, to whoever happens to be looking at their phone. Someone who tried at eleven got nothing, and a person who opened the app at three got a thali. The same stock sold out twice, and neither time did it go to the people who had asked first.

The first idea most teams reach for is a faster counter: keep the stock in Redis, which can do a hundred thousand decrements a second. A temple fair handles the same kind of crowd in another way. People take a numbered token at the gate, and a box is set aside only for someone who has reached the counter to pay, so the crowd never pushes into the kitchen. The website let the whole crowd into checkout at once. Before reading on, work out how long 20,000 taps take to drain through 50 database connections at 90 ms each.

Pause and predict The sale has two problems. 20,000 taps in two seconds take every database connection, and 124 boxes stay claimed by people who never pay. Which change fixes both problems?

What must never go wrong?

The first two rows of the table matter most: never promise a box that does not exist, and serve people in the order they asked.

Core entities, in two minutes

A Sale (dish, quantity, start, per-customer limit), a Hold (one customer’s claim on one box, with an expiry), an Order (a paid hold), and a Ticket (a place in the waiting line). The box is the only scarce thing. The other entities record who may claim a box, and for how long.

The estimate that changes the design

Twenty thousand taps in two seconds is 10,000 a second. The everyday checkout path runs on a pool of 50 database connections, each busy for about 90 ms of checkout work.

Pause and predict All 20,000 taps go through this checkout, although only the first 500 can get a thali. How long until the last person gets an answer?

What the estimate rules out: making the update on the stock row faster does not help, because the crowd spends those 36 seconds waiting for a connection before it reaches the row. The buyers who cannot win must get their answer from something that does not use a database connection.

The everyday checkout, pointed at one row

The first version reuses the normal checkout. Validate the cart, price it, insert the order, then claim the thali with lesson 2.3’s conditional update: UPDATE dishes SET stock = stock - 1 WHERE id = $1 AND stock > 0. Then send the customer to pay.

Run the sale with the conditional decrement and read every number.

Pause and predict Keep the conditional decrement and slide “Buyers who finish paying” to 100%, so that everyone who claims a box pays for it. Does that fix the sale?

Three separate failures show up, and each needs its own fix:

  • The count is correct, unless someone changes how it is written. Code that reads the stock and then writes a new value, without a lock, sells thousands of thalis that do not exist. Lesson 2.3’s conditional update and SELECT … FOR UPDATE both keep the count exact; FOR UPDATE holds the row for the whole rest of the checkout, so it sells more slowly and makes everyone wait longer.
  • Unpaid claims never end. About a quarter of the people who claim a box never pay, and nothing ever releases their claim. The stock count is accurate, but those boxes are neither sold nor available to anyone else.
  • The crowd fills the pool. The 19,500 people who cannot win still occupy a database connection for a full checkout each, and so does every ordinary lunch order behind them.

A mental model

Inventory hold (reservation)
A temporary claim on one unit of something scarce, made before payment, that turns into a sale when paid and is released if it is not paid in time.
For example: Hold h91: one thali for one customer, expires 11:05:12. Paid at 11:01, it becomes an order. Unpaid, it returns to stock at 11:05:12.

The design has three parts, each answering one failure: a waiting room for the crowd, a hold with an expiry for the people who will not pay, and arrival order kept all the way through, so a returned box goes to the next person who asked.

How long should a hold last?

With the waiting room in place, the last open question is the hold time. Two good reasons pull in opposite directions.

You can try both in the sale lab in Break it, with the waiting room on. Ten minutes means each round of returned boxes waits ten minutes, so the last one sells near 11:50. Three minutes finishes by 11:15 with no refunds, but only because every payer in the simulator finishes within 150 seconds. Real slow payers can take longer, and the simulator shows what a hold that is too short costs: at two minutes it already produces 153 refunds. So the worry about slow payers is real. Most people do pay within two minutes, and that statement hides a minority who take longer and would each need a refund.

What to remember

  1. A conditional update stops overselling. It does nothing about the crowd or about people who never pay, so the rest of the design has to handle those two problems.
  2. A hold with an expiry is a claim that ends if it is not paid in time. Change the stock and the hold in one transaction, and run a process (a sweeper) that does the expiring.
  3. A waiting room answers the many who cannot win without touching the database, keeps arrival order, and protects everyone else’s orders.
  4. Returned boxes go to the next person in line; fairness is a requirement you can measure.
  5. Late payments are a designed path: confirm only active holds, then claim afresh or refund.

Put the crowd in a queue before it reaches the database, and give every hold an expiry time.

In the real world

Real ticketing systems add what our toy leaves out: bot detection before the waiting room (a waiting room in arrival order rewards whoever arrives first, so scripts try hard to arrive first), lotteries instead of first-come for very large launches, seat maps, carts that hold several items at once, and payment flows with their own timeouts. Our simulator lets everyone in the line wait as long as it takes; real people give up, which a real room must measure.

The core of the design stays the same as yours: a line in front, a hold with an expiry, and one transaction that keeps the count correct. The payment that turns a hold into an order is its own design, in lesson 6.7, after three lessons on keeping several services in agreement.

Open the interactive lesson

The interactive lesson adds the predictions with their answers, the labs, and graded practice with spaced review.