Skip to main content
Back to Blog
7 min read

QR check-in at scale: 12K+ scans/event without a crash

How I built a QR check-in system that handles 3,000-seat events with offline support, JWT-signed tickets, and sub-200ms scan times. Architecture, code, and production numbers.

performancesecurityreact-native

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

  1. Speed: <200ms from scan to green/red screen. Staff can't wait.
  2. Offline: Venues have terrible WiFi. The scanner must work without connectivity.
  3. Duplicate detection: If a ticket is scanned twice, the second scan must show a warning — even across different scanners.
  4. No overselling: A QR code that's been refunded or transferred must be rejected instantly.
  5. 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 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:

  1. Scanner B's check-in hits the server first (it's online). Server records it.
  2. Scanner A comes back online, replays its queue. Server sees ticket #42 is already checked in.
  3. Server responds with { duplicate: true, scannerId: 'B', timestamp: ... }.
  4. 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:

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:

MetricValue
Tickets processed12,400+
Avg scan-to-result time120ms
P95 scan-to-result time180ms
Uptime99.95%
Overselling incidents0
Duplicate scan conflicts23 (auto-resolved)
Staff throughput~500 attendees/hour/scanner
Largest single event3,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

  1. 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.

  2. 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.

  3. 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.