Cinema Booking Rate Limiting
Cinema booking systems face intense pressure during popular movie releases. Bots and scalpers try to grab premium seats, while legitimate customers struggle to complete purchases. This example shows how to implement fair rate limiting for ticket booking.
Scenario
- Seat availability check: 30 per minute (prevent scraping)
- Seat hold/reserve: 5 per 10 minutes (prevent hoarding)
- Ticket purchase: 3 per hour (prevent bulk buying)
- Showtime search: 60 per minute (allow browsing)
- Premiere bookings: Special stricter limits
Implementation
src/middleware/cinema-limits.ts
import { hitlimit } from '@joint-ops/hitlimit'
import { redisStore } from '@joint-ops/hitlimit/stores/redis'
const redis = redisStore({
url: process.env.REDIS_URL,
keyPrefix: 'cinema:rl:'
})
// Showtime search - allow normal browsing
export const showtimeSearchLimit = hitlimit({
store: redis,
limit: 60,
window: '1m',
key: (req) => req.user?.id || req.ip,
response: () => ({
error: 'SEARCH_RATE_LIMITED',
message: 'Too many searches. Please wait a moment.'
})
})
// Seat availability - prevent real-time scraping
export const seatCheckLimit = hitlimit({
store: redis,
limit: 30,
window: '1m',
key: (req) => {
const showingId = req.params.showingId
return `seats:${ req.user?.id || req.ip }:${ showingId }`
},
response: () => ({
error: 'SEAT_CHECK_LIMITED',
message: 'Please slow down. Seats update every few seconds.'
})
})
// Seat reservation (hold) - prevent hoarding
export const seatHoldLimit = hitlimit({
store: redis,
limit: 5,
window: '10m',
key: (req) => {
// Combine user and IP to catch multi-account abuse
return `hold:${ req.user?.id || 'guest' }:${ req.ip }`
},
response: (info) => ({
error: 'SEAT_HOLD_LIMITED',
message: `You can only hold seats ${ info.limit } times per 10 minutes. Try again in ${ Math.ceil(info.resetIn / 60) } minutes.`,
retryAfter: info.resetIn
}),
onStoreError: () => 'deny'
})
// Ticket purchase - strict limit
export const purchaseLimit = hitlimit({
store: redis,
limit: 3,
window: '1h',
key: (req) => {
// Track by user, IP, and payment method
const userId = req.user?.id || 'guest'
return `purchase:${ userId }:${ req.ip }`
},
response: () => ({
error: 'PURCHASE_RATE_LIMITED',
message: 'Purchase limit reached. Please try again later.'
}),
onStoreError: () => 'deny' // Critical - always fail closed
})
// Per-showing ticket limit (max tickets per person per showing)
export const perShowingLimit = hitlimit({
store: redis,
limit: 10, // Max 10 tickets per person per showing
window: '7d',
key: (req) => {
const showingId = req.body.showingId
return `showing:${ req.user?.id }:${ showingId }`
},
response: () => ({
error: 'TICKET_LIMIT',
message: 'Maximum ticket limit reached for this showing.'
}),
onStoreError: () => 'deny'
}) Route Configuration
src/routes/booking.ts
import { Router } from 'express'
import * as limits from '../middleware/cinema-limits'
const router = Router()
// Browsing (public)
router.get('/movies', movieController.list)
router.get('/movies/:id/showtimes', limits.showtimeSearchLimit, showtimeController.list)
// Seat selection
router.get('/showings/:showingId/seats', limits.seatCheckLimit, seatController.getAvailable)
router.post('/showings/:showingId/hold', limits.seatHoldLimit, seatController.hold)
router.delete('/showings/:showingId/hold', seatController.release)
// Purchase (strict)
router.post('/purchase',
limits.purchaseLimit,
limits.perShowingLimit,
purchaseController.complete
)
export default router Premiere & High-Demand Handling
Opening Night Strategy
Major releases like Marvel premieres or Star Wars openings require special handling. Apply stricter limits during high-demand periods to ensure fair access.
src/middleware/premiere-limits.ts
import { hitlimit } from '@joint-ops/hitlimit'
import { redisStore } from '@joint-ops/hitlimit/stores/redis'
const redis = redisStore({ url: process.env.REDIS_URL })
// Check if showing is a premiere (first 3 days)
const isPremiere = async (showingId: string): Promise<boolean> => {
const showing = await db.showings.findById(showingId)
const movie = await db.movies.findById(showing.movieId)
const releaseDate = new Date(movie.releaseDate)
const daysSinceRelease = (Date.now() - releaseDate.getTime()) / (1000 * 60 * 60 * 24)
return daysSinceRelease < 3
}
// Stricter limits for premiere showings
export const premiereSeatHold = hitlimit({
store: redis,
limit: 2, // Only 2 holds per 10 min during premieres
window: '10m',
key: (req) => `premiere:hold:${ req.user?.id || req.ip }`,
skip: async (req) => {
// Only apply to premiere showings
return !(await isPremiere(req.params.showingId))
},
response: () => ({
error: 'PREMIERE_LIMIT',
message: 'High demand - limited holds during premiere period.'
}),
onStoreError: () => 'deny'
})
// Stricter purchase limits for premieres
export const premierePurchase = hitlimit({
store: redis,
limit: 1, // Only 1 purchase per 30 min during premieres
window: '30m',
key: (req) => `premiere:purchase:${ req.user?.id || req.ip }`,
skip: async (req) => {
return !(await isPremiere(req.body.showingId))
},
response: () => ({
error: 'PREMIERE_PURCHASE_LIMIT',
message: 'High demand - please wait before purchasing more tickets.'
}),
onStoreError: () => 'deny'
})
// Limit tickets per premiere showing
export const premiereTicketLimit = hitlimit({
store: redis,
limit: 4, // Max 4 tickets during premiere
window: '7d',
key: (req) => `premiere:tickets:${ req.user?.id }:${ req.body.showingId }`,
skip: async (req) => {
return !(await isPremiere(req.body.showingId))
},
response: () => ({
error: 'PREMIERE_TICKET_LIMIT',
message: 'Maximum 4 tickets per person during premiere period.'
}),
onStoreError: () => 'deny'
}) Virtual Queue Pattern
For extremely high-demand releases, implement a virtual queue instead of just rate limiting. Rate limiting controls access to the queue itself.
src/middleware/queue-limits.ts
import { hitlimit } from '@joint-ops/hitlimit'
import { redisStore } from '@joint-ops/hitlimit/stores/redis'
const redis = redisStore({ url: process.env.REDIS_URL })
// Queue entry - one entry per user
export const queueEntryLimit = hitlimit({
store: redis,
limit: 1,
window: '1h',
key: (req) => `queue:entry:${ req.user.id }`,
response: () => ({
error: 'ALREADY_IN_QUEUE',
message: 'You are already in the queue.'
}),
onStoreError: () => 'deny'
})
// Queue position check - prevent hammering
export const queueCheckLimit = hitlimit({
store: redis,
limit: 10,
window: '1m',
key: (req) => `queue:check:${ req.user.id }`,
response: () => ({
error: 'QUEUE_CHECK_LIMITED',
message: 'Position updates automatically. Please wait.'
})
}) Cinema Booking Best Practices
- Combine user + IP: Catch multi-account abuse by keying on both identifiers
- Per-showing limits: Prevent bulk buying for specific showtimes
- Dynamic premiere detection: Automatically apply stricter limits for new releases
- Seat hold timeouts: Combine rate limiting with short hold expiration (5-10 min)
- Virtual queues for mega-releases: Rate limiting alone won't handle viral demand
- Always fail closed: For purchases and holds, reject if rate limiting unavailable