Designing an Event Ticketing System: Seat Inventory, Flash Sale Concurrency, and Bot Mitigation at Scale
A production-focused walkthrough of event ticketing system design covering seat map data modeling, TTL-based inventory holds, distributed and optimistic locking under flash sale concurrency, payment timeout coordination, bot mitigation strategies, and dynamic pricing tradeoffs.
When a popular artist announces a tour, tens of thousands of people hit the ticketing page within seconds. The on-sale window is short, inventory is finite, and every user believes they deserve a seat. Your system has to handle concurrent checkout attempts, prevent overselling, expire abandoned holds, keep bots out, and potentially adjust prices in real time, all without losing a single legitimate transaction to a race condition.
This is one of the harder system design problems because it combines three hard sub-problems: inventory consistency under extreme concurrency, adversarial traffic from scalper bots, and the UX constraint that users get meaningful hold timers. Solving any one of them in isolation is straightforward. Solving all three together without making each worse is the design challenge.
Seat Map Data Modeling
A ticketing system deals in individual seats, not fungible units. A seat is a specific physical location with a section, row, and seat number. That specificity drives everything downstream.
interface Venue {
venueId: string;
name: string;
capacity: number;
}
interface Section {
sectionId: string;
venueId: string;
name: string; // "Floor A", "Section 101"
rowCount: number;
seatsPerRow: number;
}
interface Seat {
seatId: string; // globally unique, e.g. "venue:123:sec:A:row:5:seat:12"
sectionId: string;
row: string;
number: number;
accessibility: boolean;
restricted: boolean; // obstructed view, partial view
}
type SeatStatus = "available" | "held" | "sold" | "unavailable";
interface SeatInventory {
seatId: string;
eventId: string;
status: SeatStatus;
holdId: string | null;
holdExpiresAt: string | null; // ISO 8601
orderId: string | null;
version: number; // optimistic lock version
}
The version field is load-bearing. Every write to SeatInventory increments it and checks the prior value. Two concurrent writes against the same row cannot both succeed. This is the foundation of the concurrency model.
For the seat map UI, you do not want to query individual rows per seat. Store a denormalized section availability count in a separate read model that gets updated asynchronously after each inventory change. The map renders from that count; the authoritative per-seat state lives in the source of truth table.
Inventory Holds with TTL Expiration
The user journey is: select seats, hold them, complete payment, confirm order. The hold is the contract between selection and payment. Without it, two users could select the same seat, both enter payment details, and the second one to complete would find the inventory already gone.
A hold reserves the seat for a fixed window (typically 8 to 15 minutes). If payment does not complete within that window, the hold expires and the seats return to available inventory.
interface SeatHold {
holdId: string;
sessionId: string;
eventId: string;
seatIds: string[];
createdAt: string;
expiresAt: string; // createdAt + holdDurationMs
status: "active" | "converted" | "expired" | "released";
}
The two mechanisms for expiring holds are polling and event scheduling.
Polling runs a job every N seconds and releases holds where expiresAt < NOW() AND status = 'active'. It is simple and reliable but introduces latency proportional to the poll interval. During a flash sale, a 30-second poll interval means seats can be locked for 30 extra seconds past expiry. That is a meaningful UX problem when inventory is scarce.
Event scheduling fires a delayed job at the exact expiry time. Redis supports this with keyspace notifications on key expiration. When you create a hold, you set a Redis key with the hold ID and a TTL matching the hold duration. When the key expires, a subscriber picks up the event and releases the hold in the database.
async function createHold(
seats: string[],
sessionId: string,
eventId: string,
holdDurationMs: number
): Promise<SeatHold> {
const holdId = crypto.randomUUID();
const now = new Date();
const expiresAt = new Date(now.getTime() + holdDurationMs);
// Atomic multi-seat claim via Lua script in Redis
// Falls through to DB write only if all seats are available
const hold: SeatHold = {
holdId,
sessionId,
eventId,
seatIds: seats,
createdAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
status: "active",
};
await db.transaction(async (tx) => {
// Optimistic lock: claim each seat with version check
for (const seatId of seats) {
const result = await tx.query(
`UPDATE seat_inventory
SET status = 'held', hold_id = $1, hold_expires_at = $2, version = version + 1
WHERE seat_id = $3
AND event_id = $4
AND status = 'available'`,
[holdId, expiresAt.toISOString(), seatId, eventId]
);
if (result.rowCount === 0) {
throw new Error(`Seat ${seatId} no longer available`);
}
}
await tx.query(
`INSERT INTO seat_holds (hold_id, session_id, event_id, seat_ids, created_at, expires_at, status)
VALUES ($1, $2, $3, $4, $5, $6, 'active')`,
[holdId, sessionId, eventId, JSON.stringify(seats), now.toISOString(), expiresAt.toISOString()]
);
});
// Schedule expiry job
await redis.set(`hold:expiry:${holdId}`, "1", { PX: holdDurationMs });
return hold;
}
The expiry handler resets seat status only if the hold is still active and not converted to a paid order. Always check both conditions to avoid releasing seats that were legitimately purchased during the hold window.
Flash Sale Concurrency: Distributed Locks vs. Optimistic Locking
Flash sales create the worst case: thousands of users attempting to claim the same handful of available seats in the first few seconds after on-sale. You have two primary concurrency strategies, and they have very different characteristics.
Distributed locks (via Redis Redlock or a single-node SETNX with TTL) serialize access to a seat at the application layer. Before any write attempt, the process acquires a lock on the seat ID. Other processes wait or fail fast. This eliminates conflicts at the database layer but introduces latency per request (one Redis round-trip to acquire, one to release) and risks lock holder failure leaving seats locked until the TTL fires.
Optimistic locking allows all processes to attempt the write simultaneously and uses the database to detect conflicts. The WHERE status = 'available' check in the UPDATE statement is the conflict detector. A process that loses the race gets zero rows updated and surfaces an error to the user immediately.
For event ticketing specifically, optimistic locking at the database layer is usually the right default. The contention window is extremely short (the query execution time), the conflict signal is immediate, and there is no lock-manager overhead per seat. Use SELECT FOR UPDATE SKIP LOCKED when you need to pick an arbitrary available seat from a section rather than claiming a specific seat ID.
// "Best available" seat selection in a section
async function claimBestAvailable(
sectionId: string,
eventId: string,
count: number
): Promise<string[]> {
const result = await db.query(
`WITH candidates AS (
SELECT seat_id
FROM seat_inventory
WHERE section_id = $1
AND event_id = $2
AND status = 'available'
ORDER BY row ASC, number ASC
LIMIT $3
FOR UPDATE SKIP LOCKED
)
UPDATE seat_inventory si
SET status = 'held'
FROM candidates c
WHERE si.seat_id = c.seat_id
RETURNING si.seat_id`,
[sectionId, eventId, count]
);
if (result.rows.length < count) {
throw new Error("Not enough available seats in section");
}
return result.rows.map((r) => r.seat_id);
}
SKIP LOCKED is critical here. Without it, the query blocks until competing transactions release their locks. With it, already-locked rows are skipped and the query picks the next available candidate without waiting. This keeps throughput high during peak demand.
Distributed locks add value when you need to coordinate across systems that do not share a database, or when the seat selection workflow involves external calls (payment pre-auth) that must happen atomically with the reservation. In those cases, hold the lock for the minimum duration and build an unlock path into every error handler.
Payment Timeout Coordination
The hold-to-purchase flow introduces a timing problem. A user holds seats at T=0. Payment processing starts at T=2 minutes. The hold expires at T=10 minutes. The payment processor confirms at T=11 minutes. The hold is already released; the seats may already be held by another user.
Production systems handle this with two mechanisms. First, extend the hold when payment processing begins. When the user submits payment, extend the expiresAt by 5 minutes and update the Redis TTL key to match. This gives the payment processor enough runway without indefinitely locking the seat.
Second, implement idempotent order confirmation. The payment webhook from the processor should carry a unique event ID. The order confirmation handler must be idempotent: if the webhook arrives after hold expiry but before another user has claimed the seat, re-acquire the seat and confirm the order. If another user has taken the seat, issue a refund and notify the original buyer.
async function confirmPayment(
holdId: string,
paymentEventId: string
): Promise<void> {
// Idempotency check
const existing = await db.query(
`SELECT order_id FROM orders WHERE payment_event_id = $1`,
[paymentEventId]
);
if (existing.rows.length > 0) return;
await db.transaction(async (tx) => {
const hold = await tx.query(
`SELECT * FROM seat_holds WHERE hold_id = $1 FOR UPDATE`,
[holdId]
);
if (!hold.rows[0]) throw new Error("Hold not found");
const { seat_ids, session_id, event_id, status } = hold.rows[0];
// Allow conversion even if hold is technically expired
// as long as seats are still held by this holdId
const seats = await tx.query(
`SELECT seat_id FROM seat_inventory
WHERE seat_id = ANY($1)
AND hold_id = $2
AND status = 'held'`,
[seat_ids, holdId]
);
if (seats.rows.length !== seat_ids.length) {
throw new Error("Seats no longer held by this session");
}
const orderId = crypto.randomUUID();
await tx.query(
`UPDATE seat_inventory
SET status = 'sold', order_id = $1, hold_id = NULL, hold_expires_at = NULL
WHERE seat_id = ANY($2)`,
[orderId, seat_ids]
);
await tx.query(
`UPDATE seat_holds SET status = 'converted' WHERE hold_id = $1`,
[holdId]
);
await tx.query(
`INSERT INTO orders (order_id, session_id, event_id, seat_ids, payment_event_id, created_at)
VALUES ($1, $2, $3, $4, $5, NOW())`,
[orderId, session_id, event_id, JSON.stringify(seat_ids), paymentEventId]
);
});
}
The key insight: the hold_id on seat_inventory rows acts as a claim token. As long as that token matches, the payment confirmation can succeed even if the expiresAt timestamp has passed, because no other process has claimed the seat yet.
Bot Mitigation
Scalper bots are a serious operational problem for high-demand events. A well-resourced bot can acquire tickets faster than any human and resell them at markup. The mitigations operate at different layers of the stack.
Device fingerprinting collects browser signals (user agent, canvas fingerprint, WebGL renderer, screen dimensions, timezone, installed fonts) to build a device signature. Returning the same signature across too many simultaneous sessions flags the device. Fingerprinting degrades against headless browsers and VMs, but it raises the cost of operating a bot at scale.
Proof-of-work challenges impose a computational cost on each session before allowing checkout. The server issues a challenge (find an input that hashes to a target prefix with N leading zeros), the client solves it in a Worker thread, and sends the solution back. Legitimate users solve it in under a second on modern hardware. Bot operators running thousands of headless instances see CPU costs multiply linearly. The challenge difficulty can be dialed up per event based on demand signals.
interface PowChallenge {
challengeId: string;
prefix: string; // required leading zeros in SHA-256 hex output
difficulty: number; // e.g. 4 means 4 leading hex zeros
issuedAt: string;
expiresAt: string;
}
async function verifyPowSolution(
challengeId: string,
nonce: string
): Promise<boolean> {
const challenge = await redis.get(`pow:${challengeId}`);
if (!challenge) return false;
const { prefix, difficulty } = JSON.parse(challenge) as PowChallenge;
const hash = crypto
.createHash("sha256")
.update(`${prefix}:${nonce}`)
.digest("hex");
return hash.startsWith("0".repeat(difficulty));
}
Behavioral analysis tracks interaction patterns: time from page load to seat selection, mouse movement entropy, typing cadence on form fields, scroll behavior. Bots tend to move directly to the checkout endpoint with minimal interaction and unrealistic timing. A scoring function combines these signals and can trigger a CAPTCHA challenge for sessions that exceed a suspicion threshold.
Queue position enforcement is underrated. Rather than letting all users hit the ticketing page simultaneously, place users in a virtual waiting room on page load. Each user gets a session token with a queue position. When their turn comes, they get a time-limited access window to the actual checkout flow. This converts a spike into a controlled stream, reduces the peak concurrency on your database by orders of magnitude, and makes bot volume easier to detect because bots will not wait in a queue.
Rate limiting at the CDN or API gateway layer provides a baseline: limit hold creation attempts per IP, per device fingerprint, and per user account. The limits should be event-aware (tighter for high-demand on-sales, looser for general availability periods).
Dynamic Pricing Under Demand Pressure
Dynamic pricing adjusts seat prices based on real-time demand signals: current availability, rate of hold creation, historical demand for similar events, and time remaining before the event. The goal is to capture more value from scarce inventory without creating a perception problem with buyers.
The simplest implementation is a tier ladder: prices step up as availability crosses predefined thresholds. Section A starts at $150; when fewer than 20% of seats remain, the price moves to $175. This requires no ML, is predictable, and is easy to explain to buyers.
A more sophisticated approach models demand elasticity per section and time window. The pricing function takes current availability ratio, hold velocity (holds per minute over the last 5 minutes), and time until on-sale closes, and outputs a multiplier against the base price.
The critical constraint: price changes must not affect active holds. A user who holds seats at $150 must pay $150 even if the price has since risen to $175. Store the price at hold creation time in the seat_holds record and use that for order confirmation.
Consistency vs. Availability Tradeoffs
| Scenario | Consistency-first approach | Availability-first approach |
|---|---|---|
| Seat hold under load | Serializable isolation, fail fast on conflict | Eventual consistency with compensation |
| Hold expiry | Immediate release via event scheduler | Poll-based with tolerance for brief over-hold |
| Available count display | Real-time from source of truth | Cached count with short TTL, may show stale |
| Payment confirmation | Block on DB transaction | Accept payment, reconcile async |
| Section availability | Per-seat status from DB | Aggregated count from read model |
| Price at checkout | Locked at hold creation | Recalculate on payment submit |
For seat-level writes (holds, conversions, releases), lean toward consistency. An oversold seat is worse than a failed hold. The user can retry; an oversold order requires manual intervention, refunds, and customer service.
For the read path (seat map display, availability counts), serve from a cached read model. The seat map does not need to be perfectly accurate at 50ms intervals. A 2-second staleness window on availability counts is acceptable. What matters is that the write path, where seats are actually claimed, is strictly consistent.
Production Considerations
Database index shape matters. The hot query pattern during a flash sale is seat lookup by event_id and status. Index on (event_id, status, section_id). The hold_id lookup for expiry also needs an index. Monitor index bloat from the high write volume; dead tuples accumulate fast.
Connection pool limits. Peak concurrency during an on-sale can exhaust database connections before CPU becomes the bottleneck. Use PgBouncer in transaction mode to multiplex application connections across a smaller DB pool. Transaction-mode pooling is compatible with the short-transaction pattern used here (no session-level state).
Redis single-node for expiry. The hold expiry keyspace notification subscriber should connect to a single Redis node, not a cluster, because keyspace notifications in Redis Cluster only fire on the node owning the key. If you use a cluster, use a separate single-node Redis instance for expiry tracking or run an expiry job in the database layer.
Audit every state transition. The seat_holds and seat_inventory tables are your audit trail. Do not delete records on expiry; update the status field. When a hold expires, log the expiry reason, the time delta from scheduled expiry, and which process triggered the release. This data is essential for debugging double-purchase reports and for chargebacks.
Load test the on-sale pattern specifically. General load testing misses the flash sale spike. Simulate a coordinated surge: thousands of concurrent sessions attempting holds within the first 10 seconds. Watch for lock contention, connection exhaustion, and Redis CPU spikes from the expiry key churn. The system that handles 1,000 req/s over 10 minutes will fail at 10,000 req/s over 10 seconds if the concurrency model is not validated.
Closing
Event ticketing is where abstract system design principles meet real consequences. Overselling a seat means a customer arrives at a venue with no seat. A bot buyout means legitimate fans cannot get tickets. A payment timeout race means someone pays for something they do not receive.
The design choices here compound: the data model enables the locking strategy, the locking strategy shapes the hold lifecycle, the hold lifecycle determines the payment timeout budget, and the bot mitigation has to work without adding latency to the hold creation path. Getting the seat-level version column and the FOR UPDATE SKIP LOCKED pattern right is the foundation everything else builds on. Start there and the rest of the system has a surface to stand on.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.