4. Waiting Room
At 10:00, 2 million fans press "Buy" for 60 thousand seats. Kafka protects notification from the burst, but the burst first hits the synchronous path: seat holds and orders, backed by a strongly consistent inventory that cannot scale to 2 million requests per second. The usual answer of ticketing and sneaker-drop sites is a virtual waiting room: a queue in front of the system that lets fans in at the rate the system can serve, in arrival order, and tells everyone else their position honestly.
flowchart LR
fans["2 M fans"] -->|POST join| wr["waiting room (Redis FIFO)"]
wr -->|"position, ETA"| fans
adm["admitter (every 1 s)"] -->|"ZPOPMIN N"| wr
adm -->|"admitted ticket, TTL 5 min"| redis[(Redis)]
fans -->|"POST /orders + X-Admission-Ticket"| order[order]
order -->|"ticket admitted?"| redis This is queue-based load leveling: arrivals are bursty, service is constant, and the queue holds the difference. Little's Law gives the honest ETA a fan sees:
Design
| Decision | Choice here | Why |
|---|---|---|
| Queue store | a Redis sorted set per show, score = arrival sequence | O(log n) join and rank, atomic pop of the N first (ZPOPMIN) |
| Fairness | sequence from INCR, not client timestamps | clients' clocks lie; the server decides the order |
| Admission | every second, pop admit-per-second tickets and mark them admitted with a TTL | the rate is the knob; the TTL frees capacity of fans who give up |
| Gate | POST /orders requires an admitted X-Admission-Ticket while the on-sale is open | without enforcement, bots skip the queue |
| Position | the client polls GET /tickets/{id} | simple; at scale, push via SSE/WebSocket or long polling |
Where the gate belongs
The gate must cover every expensive step of the on-sale (seat holds in inventory and orders), so in production it lives at the edge: a gateway filter (lab 4) or a CDN waiting-room product. This lab enforces it in order to stay small; moving it to the gateway is a good extension.
Code (in the order service)
package boxoffice.order.waitingroom;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class WaitingRoom {
private static final String OPEN_SHOWS = "wr:open";
private final StringRedisTemplate redis;
private final WaitingRoomProperties props;
public WaitingRoom(StringRedisTemplate redis, WaitingRoomProperties props) {
this.redis = redis;
this.props = props;
}
private static String queue(String showId) { return "wr:" + showId + ":queue"; }
private static String seq(String showId) { return "wr:" + showId + ":seq"; }
private static String admitted(String showId, String ticket) { return "wr:" + showId + ":admitted:" + ticket; }
public void open(String showId) { redis.opsForSet().add(OPEN_SHOWS, showId); }
public void close(String showId) { redis.opsForSet().remove(OPEN_SHOWS, showId); }
public boolean isOpen(String showId) {
return Boolean.TRUE.equals(redis.opsForSet().isMember(OPEN_SHOWS, showId));
}
public Ticket join(String showId) {
String ticket = UUID.randomUUID().toString();
Long arrival = redis.opsForValue().increment(seq(showId)); // the server decides the order
redis.opsForZSet().add(queue(showId), ticket, arrival);
return status(showId, ticket);
}
public Ticket status(String showId, String ticket) {
// queue first: the admitter moves a ticket from the queue to "admitted" in one atomic step
Long rank = redis.opsForZSet().rank(queue(showId), ticket);
if (rank != null) {
long position = rank + 1;
long eta = (long) Math.ceil((double) position / props.admitPerSecond()); // Little's Law
return Ticket.waiting(ticket, position, eta);
}
String key = admitted(showId, ticket);
if (Boolean.TRUE.equals(redis.hasKey(key))) {
return Ticket.admitted(ticket, redis.getExpire(key, TimeUnit.SECONDS));
}
return Ticket.unknown(ticket); // never joined, or admission expired
}
/** ZPOPMIN + SET in one Lua script: a polling fan never sees a ticket that is neither queued nor admitted. */
private static final RedisScript<Long> POP_AND_ADMIT = RedisScript.of("""
local popped = redis.call('ZPOPMIN', KEYS[1], ARGV[1])
for i = 1, #popped, 2 do
redis.call('SET', ARGV[2] .. popped[i], '1', 'EX', ARGV[3])
end
return #popped / 2
""", Long.class);
/** Runs on ONE instance only (see pitfalls): N replicas would admit N times the rate. */
@Scheduled(fixedRate = 1000)
public void admit() {
Set<String> shows = redis.opsForSet().members(OPEN_SHOWS);
if (shows == null) {
return;
}
for (String showId : shows) {
redis.execute(POP_AND_ADMIT, List.of(queue(showId)), String.valueOf(props.admitPerSecond()),
admitted(showId, ""), String.valueOf(props.admissionTtl().toSeconds()));
}
}
public boolean mayEnter(String showId, String ticket) {
if (!isOpen(showId)) {
return true; // no on-sale running for this show: no gate
}
return ticket != null && Boolean.TRUE.equals(redis.hasKey(admitted(showId, ticket)));
}
}
package boxoffice.order.waitingroom;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Ticket(String ticketId, String status, Long position, Long etaSeconds, Long expiresInSeconds) {
static Ticket waiting(String id, long position, long eta) {
return new Ticket(id, "WAITING", position, eta, null);
}
static Ticket admitted(String id, Long expiresIn) {
return new Ticket(id, "ADMITTED", null, null, expiresIn);
}
static Ticket unknown(String id) {
return new Ticket(id, "UNKNOWN", null, null, null);
}
}
package boxoffice.order.waitingroom;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "boxoffice.waiting-room")
public record WaitingRoomProperties(
@DefaultValue("20") int admitPerSecond,
@DefaultValue("5m") Duration admissionTtl) {
}
package boxoffice.order.waitingroom;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/orders/waiting-room/{showId}")
public class WaitingRoomController {
private final WaitingRoom room;
public WaitingRoomController(WaitingRoom room) {
this.room = room;
}
/** Operator endpoints: in production, protect them (admin role at the gateway). */
@PostMapping("/open")
public ResponseEntity<Void> open(@PathVariable String showId) {
room.open(showId);
return ResponseEntity.noContent().build();
}
@PostMapping("/close")
public ResponseEntity<Void> close(@PathVariable String showId) {
room.close(showId);
return ResponseEntity.noContent().build();
}
@PostMapping("/join")
public ResponseEntity<Ticket> join(@PathVariable String showId) {
return ResponseEntity.status(HttpStatus.ACCEPTED).body(room.join(showId));
}
@GetMapping("/tickets/{ticketId}")
public ResponseEntity<Ticket> ticket(@PathVariable String showId, @PathVariable String ticketId) {
Ticket t = room.status(showId, ticketId);
return "UNKNOWN".equals(t.status()) ? ResponseEntity.status(HttpStatus.NOT_FOUND).body(t)
: ResponseEntity.ok(t);
}
}
package boxoffice.order;
import boxoffice.order.waitingroom.WaitingRoom;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService orders;
private final WaitingRoom waitingRoom;
public OrderController(OrderService orders, WaitingRoom waitingRoom) {
this.orders = orders;
this.waitingRoom = waitingRoom;
}
@PostMapping
public ResponseEntity<OrderView> place(
@Valid @RequestBody OrderRequest req,
@RequestHeader(name = "X-Admission-Ticket", required = false) String ticket) {
if (!waitingRoom.mayEnter(req.showId(), ticket)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
"on-sale in progress: join /orders/waiting-room/" + req.showId() + "/join first");
}
OrderService.PlaceResult result = orders.place(req);
if (!result.created()) {
return ResponseEntity.ok(result.order());
}
return ResponseEntity.created(URI.create("/orders/" + result.order().id())).body(result.order());
}
@GetMapping("/{id}")
public OrderView get(@PathVariable UUID id) {
return orders.find(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}
}
The waiting-room properties are already in the complete services/order/src/main/resources/application.yaml of step 2; check that yours has this block (add it if you wrote the file by hand):
boxoffice:
waiting-room:
admit-per-second: ${ADMIT_PER_SECOND:20}
admission-ttl: 5m
Try it
Peek at the data structure while a k6 spike runs (step 5):
docker compose exec redis redis-cli ZCARD wr:show-42:queue
docker compose exec redis redis-cli ZRANGE wr:show-42:queue 0 4 WITHSCORES
Pitfalls
- The admitter must run once. With 3
orderreplicas, three admitters pop 3 × N per second. Run it in a single instance (a dedicated worker), elect a leader, or use a distributed lock (e.g. ShedLock) — see the scheduler discussion. - Admitted ≠bought. The TTL returns abandoned slots; a smarter admitter admits
capacity − active buyersinstead of a fixed N. - The waiting room itself must scale. Joins and polls are cheap Redis operations, but 2 M fans polling every second is 2 M requests per second: poll less often as the position is far, serve the waiting page from the CDN, and keep the queue on its own Redis.
- Bots join many times. Rate-limit joins per client and require a challenge (WAF) before joining.