Skip to content

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:

\[ \text{wait} \approx \frac{\text{position in the queue}}{\text{admission rate}} \qquad \text{e.g. } \frac{30\,000}{500\ \text{fans/s}} = 60\ \text{s} \]

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)

services/order/src/main/java/boxoffice/order/waitingroom/WaitingRoom.java
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)));
    }
}
services/order/src/main/java/boxoffice/order/waitingroom/Ticket.java
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);
    }
}
services/order/src/main/java/boxoffice/order/waitingroom/WaitingRoomProperties.java
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) {
}
services/order/src/main/java/boxoffice/order/waitingroom/WaitingRoomController.java
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);
    }
}
services/order/src/main/java/boxoffice/order/OrderController.java
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):

services/order/src/main/resources/application.yaml (additions)
boxoffice:
  waiting-room:
    admit-per-second: ${ADMIT_PER_SECOND:20}
    admission-ttl: 5m

Try it

docker compose up -d --build --wait ordercurl -s -X POST localhost:8084/orders/waiting-room/show-42/open -o /dev/null -w '%{http_code}\n'204curl -s -X POST localhost:8084/orders/waiting-room/show-42/join{"ticketId":"0c6f3b8e-0f2a-4a8e-9d55-1b7c5b3c9e21","status":"WAITING","position":1,"etaSeconds":1}curl -s localhost:8084/orders/waiting-room/show-42/tickets/0c6f3b8e-0f2a-4a8e-9d55-1b7c5b3c9e21{"ticketId":"0c6f3b8e-0f2a-4a8e-9d55-1b7c5b3c9e21","status":"ADMITTED","expiresInSeconds":298}curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \ -d '{"showId":"show-42","holdId":"hold-0100","customerEmail":"caio@example.com","amount":350.00}' \ -o /dev/null -w '%{http_code}\n'403curl -s -X POST localhost:8084/orders -H 'Content-Type: application/json' \ -H 'X-Admission-Ticket: 0c6f3b8e-0f2a-4a8e-9d55-1b7c5b3c9e21' \ -d '{"showId":"show-42","holdId":"hold-0100","customerEmail":"caio@example.com","amount":350.00}' \ -o /dev/null -w '%{http_code}\n'201

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 order replicas, 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 buyers instead 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.

Next: Measure it