At a 3,000-seat concert, you have about 30 minutes between doors opening and the first act. That's 100 scans per minute if you have one scanner. Miss that window and you get a crowd at the entrance, angry promoters, and a security problem.
I built the QR check-in system for E7 Platform — a ticketing platform that's processed 12,400+ tickets across dozens of events. The system runs on staff phones, works offline, and validates a scan in under 200ms. Here's the architecture.
The constraints
- Speed:
<200ms from scan to green/red screen. Staff can't wait. - Offline: Venues have terrible WiFi. The scanner must work without connectivity.
- Duplicate detection: If a ticket is scanned twice, the second scan must show a warning — even across different scanners.
- No overselling: A QR code that's been refunded or transferred must be rejected instantly.
- Staff-friendly: The scanner runs on any phone with a camera. No special hardware.
Pattern 1: JWT-signed QR codes (no DB lookup at scan time)
The key insight: move the database lookup to purchase time, not scan time.
When Stripe confirms payment via webhook, the server generates a QR code containing a signed JWT:
import { SignJWT } from 'jose'
async function generateTicketQR(ticket: {
id: string
eventId: string
seat: string
}) {
const token = await new SignJWT({
tid: ticket.id,
eid: ticket.eventId,
seat: ticket.seat,
})
.setProtectedHeader({ alg: 'ES256' })
.setIssuedAt()
.setExpirationTime('30d')
.sign(privateKey)
return generateQRCode(token)
}
Why ES256 (ECDSA) instead of HS256 (HMAC):
- The private key stays on the server (signs tickets)
- The public key goes to scanners (verifies tickets)
- If a scanner device is lost or compromised, the attacker can verify but not forge tickets
The QR encodes ~180 bytes. Any phone camera reads it in one frame.
Pattern 2: Offline-first scanner
The scanner is a React Native screen behind staff auth. On launch, it pulls the event's full ticket manifest:
async function syncManifest(eventId: string) {
const manifest = await api.get(`/events/${eventId}/manifest`)
// Store locally: ticket ID → status
await AsyncStorage.setItem(`manifest:${eventId}`, JSON.stringify(manifest))
return manifest
}
The manifest is a map of ticketId → { status, seat, checkedInAt, scannerId }. For a 3,000-seat event, that's ~150KB — syncs in under a second even on 3G.
Scan flow (offline-capable):
async function handleScan(qrData: string) {
// 1. Verify JWT signature (local, no network)
const payload = await verifyJWT(qrData, publicKey)
if (!payload) return { result: 'invalid' }
// 2. Check local manifest
const manifest = await getLocalManifest(payload.eid)
const ticket = manifest[payload.tid]
if (!ticket) return { result: 'invalid' }
if (ticket.status === 'refunded') return { result: 'invalid' }
if (ticket.checkedInAt) {
return {
result: 'duplicate',
checkedInAt: ticket.checkedInAt,
scannerId: ticket.scannerId,
}
}
// 3. Mark as checked in locally
ticket.checkedInAt = Date.now()
ticket.scannerId = currentScannerId
await updateLocalManifest(payload.eid, manifest)
// 4. Sync to server (fire-and-forget, retries in background)
syncQueue.push({ ticketId: payload.tid, checkedInAt: ticket.checkedInAt })
return { result: 'valid', seat: payload.seat }
}
Steps 1–3 are fully local. Step 4 syncs when connectivity is available. I wrote more about the offline-first patterns including conflict resolution.
Pattern 3: Cross-scanner sync via Redis
When connectivity is available, check-ins sync through Redis pub/sub:
// Server: receives check-in from scanner A
async function recordCheckIn(ticketId: string, scannerId: string) {
const key = `checkin:${ticketId}`
// SET NX — only succeeds if not already checked in
const set = await redis.set(key, scannerId, { NX: true, EX: 86400 })
if (!set) {
// Already checked in by another scanner
const existingScanner = await redis.get(key)
return { duplicate: true, scannerId: existingScanner }
}
// Broadcast to all scanners for this event
await redis.publish(
`event:${eventId}:checkins`,
JSON.stringify({
ticketId,
scannerId,
timestamp: Date.now(),
})
)
// Write to Postgres (async, not blocking the scan response)
await db.ticket.update({
where: { id: ticketId },
data: { checkedInAt: new Date(), scannerId },
})
return { duplicate: false }
}
SET NX is atomic — two scanners scanning the same ticket within the same millisecond still get a correct result. The first one wins, the second gets a duplicate warning.
Each scanner subscribes to the Redis channel and updates its local manifest in real-time. When a scanner comes back online after an offline period, it replays its queue and pulls the latest manifest.
Pattern 4: What about conflicts?
The interesting edge case: scanner A checks in ticket #42 offline. Scanner B (online) also scans ticket #42 a minute later.
Resolution:
- Scanner B's check-in hits the server first (it's online). Server records it.
- Scanner A comes back online, replays its queue. Server sees ticket #42 is already checked in.
- Server responds with
{ duplicate: true, scannerId: 'B', timestamp: ... }. - Scanner A's local manifest updates. No data loss, no double-entry.
The rule is simple: server timestamp wins, earliest takes priority. Conflicts are rare in practice — in 6 months of production, we had 23 conflict events across 12,400+ tickets. All resolved automatically.
Pattern 5: Refunds and transfers
A ticket refunded after the manifest was synced must be rejected at scan time. This flows through the same Redis pub/sub:
// When a refund is processed
async function handleRefund(ticketId: string, eventId: string) {
await redis.publish(
`event:${eventId}:checkins`,
JSON.stringify({
ticketId,
status: 'refunded',
timestamp: Date.now(),
})
)
}
Online scanners get the update instantly. Offline scanners get it on next sync. The window of vulnerability (offline scanner doesn't know about refund) is mitigated by the server-side reconciliation — the offline check-in replays, server rejects it, scanner updates.
In practice, refunds during an event are rare. In 6 months: 3 cases. All caught on sync.
The UX that matters
The scanner screen has exactly three states:
- Green + checkmark: Valid. Shows seat number. Audible beep.
- Yellow + warning: Already scanned. Shows when and which scanner. Different tone.
- Red + X: Invalid or refunded. Loud distinct tone.
Staff training took 5 minutes. The color + sound combination means they don't even need to read the screen in a dark venue.
Production numbers
After 6 months in production across dozens of events:
| Metric | Value |
|---|---|
| Tickets processed | 12,400+ |
| Avg scan-to-result time | 120ms |
| P95 scan-to-result time | 180ms |
| Uptime | 99.95% |
| Overselling incidents | 0 |
| Duplicate scan conflicts | 23 (auto-resolved) |
| Staff throughput | ~500 attendees/hour/scanner |
| Largest single event | 3,000 seats, 4 scanners, 25 min full check-in |
The old paper-list process at the same venue: ~120 attendees/hour, 2+ hours for full check-in.
What I'd change
-
Manifest delta sync: Currently the scanner pulls the full manifest on each sync. For events over 5K seats, a delta sync (only changed tickets since last sync) would cut bandwidth. Haven't needed it yet — 150KB full syncs are fine at current scale.
-
WebSocket for scanner sync: Currently using Redis pub/sub → server-sent events to scanners. A WebSocket connection would give true bidirectional communication and lower latency for cross-scanner updates.
-
Ticket transfer chain: Right now transfers create a new ticket ID. A chain (original → transfer → transfer) would give better audit trails for promoters.
The takeaway
The core pattern is simple: sign at purchase, verify locally, sync in background. JWT signatures eliminate the database bottleneck at scan time. Offline-first design means venue WiFi is irrelevant. Redis pub/sub handles the cross-scanner consistency that offline alone can't solve.
The hard part wasn't any single pattern — it was making them work together reliably at 100+ scans per minute with staff who have 5 minutes of training.
Building an event platform or ticketing system that needs to handle real-world check-in scale? Let's talk — I've shipped this exact architecture and can save you the months of edge cases.