Designing a Concert Ticketing System: Virtual Queue for Millions
1. Overview
A major concert goes on sale at 10:00. Just seconds later, millions of people click “Buy Ticket” to compete for roughly 50,000 seats. If you dump all these requests straight into a database, the system crashes. If you block them wrong, two people might buy the same seat.
This article walks through each design step: requirements, data model, APIs, and processing flow. The core focus is three hard problems: traffic spikes, seat competition, and consistency during payment. After reading, you’ll be able to build a virtual queue using Redis, a mechanism to prevent double-selling seats, and a safe order confirmation flow that survives retries.
2. Requirements
2.1. Functional Requirements
- Users enter the event page and are placed in a virtual queue before the sale opens.
- The system lets users into the seat selection area in batches, in order of arrival.
- Users select up to 4 seats. Seats are held for 5 minutes to complete payment.
- Successful payment makes seats transition to sold status and tickets are issued.
- If payment is not completed within the time limit, seats are returned to inventory.
2.2. Scale and Constraints
| Metric | Estimate |
|---|---|
| Concurrent users at sale time | 2,000,000 |
| Number of seats | 50,000 |
| Peak request rate to event page | ~500,000 RPS |
| Max concurrent users in seat selection area | 5,000 |
The most critical constraint is never to sell the same seat twice. Consistency here must be strong consistency. In contrast, queue position only needs to be roughly accurate: showing “about 12,000 people ahead” is sufficient.
From these two numbers, we can draw a key insight: only 2.5% of users get tickets. Therefore, the system must reject or delay most requests at the cheapest layer possible, before they touch the database.
3. Architecture and Processing Flow
Client ──> CDN (static page, waiting page)
│
├─> Queue Service ──> Redis (ZSET queue, access token)
│ ▲
│ └── Admitter (scheduled, release N people/second)
│
└─> Booking Service (requires access token)
├─> PostgreSQL (seat, order, outbox)
└─> Kafka (ticket-issued) ──> Ticket/Email Service
The main flow works like this:
- Client calls
POST /events/{id}/queueand receivesqueueId. - Client polls
GET /events/{id}/queue/{queueId}every few seconds to check position. When it’s their turn, the client receives anaccessToken. - Client uses the token to call
POST /events/{id}/holdswith a list of seats. - Client pays. The payment provider calls a webhook back; the system calls
POST /orders/{id}/confirmwith an idempotency key. - Booking Service writes the order and outbox event in the same transaction. The event is then pushed to Kafka to issue tickets.
The waiting page is served statically via CDN. Only the join and poll APIs hit the backend, and both only touch Redis.
4. Data Model
CREATE TABLE seat (
event_id BIGINT NOT NULL,
seat_id VARCHAR(16) NOT NULL,
section VARCHAR(16) NOT NULL,
price NUMERIC(12,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | SOLD
held_by BIGINT,
hold_expires_at TIMESTAMP,
PRIMARY KEY (event_id, seat_id)
);
CREATE TABLE ticket_order (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
event_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING | PAID | EXPIRED
idempotency_key VARCHAR(64) NOT NULL UNIQUE
);
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
payload TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
Seat status lives directly on the seat table. Thanks to this, holding a seat requires just one conditional UPDATE statement. The UNIQUE constraint on idempotency_key is the final barrier against duplicate order writes.
5. Virtual Queue with Redis
Add the dependency:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.1. Joining the Queue
The queue is a Redis Sorted Set. The score is an incrementing sequence number obtained from INCR:
@Service
public class QueueService {
private final StringRedisTemplate redis;
public QueueService(StringRedisTemplate redis) {
this.redis = redis;
}
public long join(long eventId, long customerId) {
String queueKey = "queue:" + eventId;
String member = String.valueOf(customerId);
Long existing = redis.opsForZSet().rank(queueKey, member);
if (existing != null) {
return existing;
}
Long seq = redis.opsForValue().increment("queue:seq:" + eventId);
redis.opsForZSet().addIfAbsent(queueKey, member, seq);
return redis.opsForZSet().rank(queueKey, member);
}
public Long position(long eventId, long customerId) {
return redis.opsForZSet().rank("queue:" + eventId, String.valueOf(customerId));
}
}
Explanation:
incrementreturns a global sequence number. You don’t use timestamp as the score because multiple requests might have the same millisecond.addIfAbsentcorresponds toZADD NX. If a user spams refresh, they won’t be pushed to the end of the queue.rank(ZRANK) has O(log N) complexity. With 2 million elements, this command still only takes a few microseconds.
Three users joining the queue in succession will get these results:
join(1, 101) -> 0
join(1, 202) -> 1
join(1, 101) -> 0 (rejoin, position preserved)
Note: To block bots, authenticate users (login, captcha) before letting them join. Once in the ZSET, a bot holds a slot just like a real user.
5.2. Admitter: Releasing Users in Batches
A job runs every second. It checks how many slots are available in the seat selection area, then pops exactly that many people from the front of the queue:
@Component
public class Admitter {
private static final int MAX_ACTIVE = 5_000;
private static final Duration TOKEN_TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
public Admitter(StringRedisTemplate redis) {
this.redis = redis;
}
@Scheduled(fixedRate = 1000)
public void admit() {
long eventId = 1L;
String activeKey = "active:" + eventId;
long now = System.currentTimeMillis();
redis.opsForZSet().removeRangeByScore(activeKey, 0, now);
long active = Optional.ofNullable(redis.opsForZSet().zCard(activeKey)).orElse(0L);
long slots = MAX_ACTIVE - active;
if (slots <= 0) return;
var batch = redis.opsForZSet().popMin("queue:" + eventId, slots);
if (batch == null) return;
for (var tuple : batch) {
String customerId = tuple.getValue();
String token = UUID.randomUUID().toString();
redis.opsForValue().set("access:" + token, customerId, TOKEN_TTL);
redis.opsForValue().set("token-of:" + eventId + ":" + customerId, token, TOKEN_TTL);
redis.opsForZSet().add(activeKey, customerId, now + TOKEN_TTL.toMillis());
}
}
}
Explanation:
active:{eventId}is a ZSET with score equal to expiration time.removeRangeByScorecleans up expired sessions, thenzCardtells you how many users are currently in the seat selection area.popMin(ZPOPMIN) retrieves and removes the top N users from the queue.- When polling, the client reads
token-of:.... If the key exists, it’s their turn, and the client receives the token.
You need to run this job on exactly one instance, for example using ShedLock or leader election. If two instances run it, the seat selection area might exceed MAX_ACTIVE.
You also don’t need to run multiple Admitter instances to handle 2 million people. The rate at which you release users is limited by Booking Service’s capacity, not Redis.
5.3. Blocking Requests Without Token
@Component
public class AccessTokenFilter extends OncePerRequestFilter {
private final StringRedisTemplate redis;
public AccessTokenFilter(StringRedisTemplate redis) {
this.redis = redis;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().contains("/holds");
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String token = req.getHeader("X-Queue-Token");
String customerId = token == null ? null : redis.opsForValue().get("access:" + token);
if (customerId == null) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "Not admitted");
return;
}
req.setAttribute("customerId", Long.valueOf(customerId));
chain.doFilter(req, res);
}
}
This filter uses the token as the lookup key, not trusting the customerId sent by the client. Users can’t cut the line by calling the /holds API directly.
6. Holding Seats: Conflict Resolution
Many people might select the same seat A-12. The simplest and correct approach is to let the database decide with one conditional UPDATE:
@Service
public class SeatHoldService {
private static final Duration HOLD_TTL = Duration.ofMinutes(5);
private final JdbcTemplate jdbc;
public SeatHoldService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Transactional
public void hold(long eventId, long customerId, List<String> seatIds) {
if (seatIds.size() > 4) throw new IllegalArgumentException("Max 4 seats");
Timestamp now = Timestamp.from(Instant.now());
Timestamp expires = Timestamp.from(Instant.now().plus(HOLD_TTL));
for (String seatId : seatIds.stream().sorted().toList()) {
int updated = jdbc.update("""
UPDATE seat SET status = 'HELD', held_by = ?, hold_expires_at = ?
WHERE event_id = ? AND seat_id = ?
AND (status = 'AVAILABLE' OR (status = 'HELD' AND hold_expires_at < ?))
""", customerId, expires, eventId, seatId, now);
if (updated == 0) {
throw new SeatUnavailableException(seatId);
}
}
}
}
class SeatUnavailableException extends RuntimeException {
SeatUnavailableException(String seatId) { super("Seat taken: " + seatId); }
}
Explanation:
- The
WHEREclause both checks and updates in one atomic statement. The database locks that row. Later requests will wait, then re-evaluate the condition and seestatus = 'HELD', so they getupdated = 0. - The
hold_expires_at < ?condition allows reclaiming already-expired holds without waiting for a cleanup job. - If any seat in the batch fails, an exception rolls back the entire transaction. Users never end up holding “partial groups” of seats.
- Sorting
seatIdsbefore update ensures all transactions lock in the same order, avoiding deadlock when two people pick overlapping sets, like {A-1, A-2} vs. {A-2, A-1}.
Note: Don’t use the pattern “SELECT to check status, then UPDATE” without locking. That’s the classic race condition leading to overselling.
6.1. Testing Anti-Duplicate Sale Behavior
Let 50 threads compete for one seat. The expected result is exactly one thread succeeds.
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
@JdbcTest
@Import(SeatHoldService.class)
@Transactional(propagation = Propagation.NOT_SUPPORTED)
class SeatHoldServiceTest {
@Autowired JdbcTemplate jdbc;
@Autowired SeatHoldService service;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
jdbc.update("DELETE FROM seat");
jdbc.update("INSERT INTO seat(event_id, seat_id, section, price) VALUES (1, 'A-12', 'VIP', 3000000)");
AtomicInteger success = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (long customer = 1; customer <= 50; customer++) {
long id = customer;
executor.submit(() -> {
try {
service.hold(1L, id, List.of("A-12"));
success.incrementAndGet();
} catch (SeatUnavailableException ignored) { }
});
}
}
assertThat(success.get()).isEqualTo(1);
assertThat(jdbc.queryForObject(
"SELECT status FROM seat WHERE seat_id = 'A-12'", String.class)).isEqualTo("HELD");
}
}
The seat table is created from src/test/resources/schema.sql. Use the exact CREATE TABLE seat from section 4, changing BIGSERIAL to BIGINT AUTO_INCREMENT if creating other tables. @Transactional(NOT_SUPPORTED) disables the transaction wrapper around the test, so data commits and other threads see it.
7. Payment and Consistency
A payment provider’s webhook might be called multiple times. Therefore, order confirmation must be idempotent:
@Transactional
public void confirm(long orderId, long customerId, long eventId, String idempotencyKey) {
int inserted = jdbc.update("""
INSERT INTO ticket_order(id, customer_id, event_id, status, idempotency_key)
VALUES (?, ?, ?, 'PAID', ?) ON CONFLICT (idempotency_key) DO NOTHING
""", orderId, customerId, eventId, idempotencyKey);
if (inserted == 0) return; // already processed
int sold = jdbc.update("""
UPDATE seat SET status = 'SOLD'
WHERE event_id = ? AND held_by = ? AND status = 'HELD'
""", eventId, customerId);
if (sold == 0) throw new IllegalStateException("Hold expired, refund required");
jdbc.update("INSERT INTO outbox(topic, payload) VALUES ('ticket-issued', ?)",
"{\"orderId\":" + orderId + "}");
}
This code handles three things:
ON CONFLICT DO NOTHING(PostgreSQL syntax) ensures that webhook retries don’t create duplicate orders.- Seats only transition to
SOLDif still held by this exact customer. If the hold expired and the seat now belongs to someone else, the transaction rolls back. The order then needs refund handling. - Transactional outbox: the event is written in the same transaction as the order. A separate process (or Debezium) reads the
outboxtable and pushes to Kafka. This prevents cases where an order was saved but no ticket issued, or vice versa.
Note: Make hold TTL longer than the payment page timeout by a small margin, e.g., hold 5 minutes and payment page 4 minutes. This buffer reduces cases where the customer paid but the hold just expired.
8. Common Pitfalls and Mistakes
- Traffic spike at entry: the waiting page must be static on CDN. The poll API should return headers suggesting poll frequency, e.g., 5–15 seconds depending on position. If clients poll every second, 2 million people generate 2 million RPS to Redis.
- Redis as single point of failure: use Redis Cluster or Sentinel. For a single event, the entire ZSET lives on one key, so it lives on one shard. A single Redis node can handle hundreds of thousands of ops/second, but measure first.
- Displaying “seats remaining” inaccurately: the client-side seat count can read from cache and tolerate seconds of staleness. Only the UPDATE statement from section 6 is the source of truth.
- Stale holds: a user holds seats then closes the tab. The expiration check in UPDATE already handles reclaiming those seats. Just add a periodic job to return expired seats to
AVAILABLEso the UI shows correctly. - When NOT to use a virtual queue: for events with a few hundred seats and moderate traffic, rate limiting combined with conditional UPDATE is enough. A virtual queue adds operational complexity and slows user experience. Use it only when demand far exceeds supply.
9. Conclusion
A ticket system handles load well by separating concerns. The virtual queue on Redis absorbs traffic spikes and ensures fairness. The database only handles the small number of users admitted and guarantees no double-selling via conditional UPDATE.
You’ve seen how to use ZADD NX and ZPOPMIN for queueing and batch release, how to hold seats atomically and avoid deadlock, and how to confirm orders idempotently while using transactional outbox to push events to Kafka. From this framework, you can extend with bot prevention, priority queues for members, or parallel multi-event ticketing.