system-design45 min read

Design a Queue Management System: The Complete Guide — A Senior+ Guide | Ayodhyya

Design a Queue Management System: The Complete Guide

Building virtual queues, real-time wait estimation, multi-service routing, and staff assignment at enterprise scale

Senior+ Guide 50+ min read 10,000+ words Ayodhyya

1. Introduction

Queue management is one of the most ubiquitous problems in distributed systems. Whether you are designing the backend for a hospital waiting room, a government service center, a bank branch network, or a virtual customer support line, the core challenge remains the same: efficiently ordering, tracking, and serving a continuous stream of arriving entities while providing accurate real-time feedback to both customers and staff.

A modern queue management system goes far beyond a simple first-in-first-out data structure. It must handle multiple service windows, priority tiers, walk-in and online customers simultaneously, real-time wait time estimation, push notifications via SMS and email, staff assignment and load balancing, analytics dashboards, multi-location support, integration with appointment scheduling systems, and strict regulatory compliance for healthcare and government use cases.

In this comprehensive guide, we will design such a system from the ground up. We will begin by defining functional and non-functional requirements, move through capacity estimation and data modeling, and then dive deep into architecture, API design, virtual queue mechanics, priority and fairness algorithms, multi-service routing, real-time estimation techniques, notification pipelines, walk-in versus online merging strategies, staff assignment logic, analytics, multi-location federation, appointment integration, security, cost modeling, and testing. We conclude with a thorough interview question and answer section that mirrors what you would encounter at senior and staff-level system design interviews.

Who is this guide for? Senior engineers, tech leads, and architects preparing for system design interviews or building production queue management platforms. All code examples are in C# using .NET 8, but the patterns are language-agnostic.

Why Queue Management is Hard

The apparent simplicity of a queue hides enormous complexity. Consider a hospital emergency department. Patients arrive unpredictably. Some are critical and must be seen immediately regardless of arrival time. Others have appointments. Some walk in for routine checkups. The department has doctors, nurses, and administrative staff, each with different capabilities. Rooms and equipment are shared resources. Wait times must be communicated to patients and their families. Regulatory bodies audit how patients were prioritized. Every one of these concerns must be modeled in the system.

From a systems perspective, we need strong consistency for queue position (no two customers should think they are number three), high availability (the system cannot go down during business hours), low latency for real-time updates, horizontal scalability for multi-location deployments, and fault tolerance so that a single node failure does not lose queue state. Balancing these non-functional requirements while keeping the codebase maintainable is the central engineering challenge.

Real-World Applications

  • Healthcare: Hospital triage, outpatient departments, vaccination centers, telemedicine queues
  • Government: DMV offices, passport offices, visa application centers, tax offices
  • Banking & Finance: Branch teller queues, loan processing queues, customer support call queues
  • Retail & Hospitality: Store checkout lines, restaurant waitlists, hotel check-in queues
  • Technology: Customer support ticket queues, DevOps incident queues, API request throttling queues
  • Education: Admission queues, counseling sessions, office hour queues

Key Design Principles

We will adhere to several guiding principles throughout this design. First, separation of concerns: the queue logic, notification pipeline, estimation engine, and analytics subsystem should be independently deployable and scalable. Second, event-driven architecture: every state change (enqueue, dequeue, serve, skip) should emit an event that downstream consumers can process asynchronously. Third, idempotency: all write operations must be idempotent so that retries do not create duplicate entries. Fourth, observability: every component should emit structured logs, metrics, and traces from day one. Finally, graceful degradation: if the notification service is down, customers should still be able to join and be served from the queue.

2. Functional & Non-Functional Requirements

Functional Requirements

IDRequirementDescription
FR-01Join QueueCustomers can join a virtual queue via mobile app, web portal, QR code scan, or kiosk. Each customer receives a unique ticket ID and position.
FR-02Real-Time PositionCustomers can view their current position in the queue and estimated wait time at any time.
FR-03Serve NextStaff can call the next customer from the queue when a service window becomes available.
FR-04Priority LevelsSupport multiple priority tiers (e.g., VIP, regular, walk-in) with configurable promotion rules.
FR-05Multi-ServiceA single location can offer multiple services, each with its own queue or shared queue.
FR-06NotificationsSend SMS, email, and push notifications for position updates, wait time changes, and turn-to-serve alerts.
FR-07Walk-In MergeWalk-in customers can be merged into the online queue with appropriate positioning rules.
FR-08Staff DashboardStaff can view current queue state, customer details, service history, and real-time metrics.
FR-09Multi-LocationSupport multiple physical locations with independent queues and centralized reporting.
FR-10Appointment IntegrationCustomers with appointments can be slotted into the queue at their scheduled time.
FR-11Skip / No-ShowHandle customers who leave the queue or do not show up when called.
FR-12AnalyticsProvide dashboards for average wait times, throughput, peak hours, and staff utilization.

Non-Functional Requirements

AttributeTargetRationale
Availability99.99%System must be available during all business hours across time zones
Latency (P99)< 200msQueue join and position queries must feel instant
Throughput10,000 joins/secPeak load for large government service centers
ConsistencyStrong for positionQueue position must never be duplicated or lost
DurabilityZero data lossQueue state must survive node failures
ScalabilityHorizontalMust scale across locations without re-architecture
SecurityHIPAA / GDPRHealthcare deployments require strict data protection
Offline SupportDegrade gracefullyKiosk mode must work during network interruptions

Out of Scope (Initial Version)

  • Payment processing for paid queue access
  • Video or voice-based remote service delivery
  • Machine learning based predictive staffing (future enhancement)
  • Mobile native applications (web-first approach initially)

3. Capacity Estimation

Let us assume we are building a queue management platform that serves 500 locations across a country. Each location averages 20 service windows and handles approximately 500 customers per day during peak operations. This gives us a baseline to estimate storage, bandwidth, and compute requirements.

Throughput Numbers

MetricCalculationResult
LocationsGiven500
Avg customers/location/dayGiven500
Total customers/day500 × 500250,000
Operations per customerJoin + updates + serve ≈ 1515
Total ops/day250,000 × 153,750,000
Ops per second (avg)3,750,000 / 86,400~43
Peak OPS (3× avg)43 × 3~130

Storage Estimation

Each queue ticket record is approximately 1 KB including metadata (ticket ID, customer info pointer, timestamps, status, priority, service type). With 250,000 tickets per day and a 90-day retention policy for active analytics:

Data TypeSize per RecordDaily VolumeDaily Storage
Queue tickets1 KB250,000250 MB
Position snapshots (every 30s)128 B250,000 × 60~1.8 GB
Events / audit log256 B3,750,000~900 MB
Notification records512 B500,000~250 MB
Total daily~3.2 GB
Storage Verdict: At 3.2 GB per day with 90-day hot retention, we need approximately 288 GB of hot storage and can archive the rest to cold storage. A single 1 TB SSD easily handles the hot data with room to spare.

Bandwidth Estimation

Average API response is 2 KB. Peak 130 requests per second gives us approximately 260 KB/s inbound. Outbound includes position updates pushed to connected clients at approximately 1 KB each. With 10,000 concurrent connected clients receiving updates every 10 seconds, push bandwidth is about 1 MB/s. Total bandwidth at peak is well under 2 MB/s, which is negligible for any modern cloud deployment.

Caching Strategy

We will cache the following in Redis:

  • Active queue state per location: Current head, tail, total count, estimated wait times (TTL: 10 seconds)
  • Customer position: Ticket ID to position mapping (TTL: until served or removed)
  • Staff session data: Which staff member is logged in and handling which window (TTL: 30 minutes)
  • Rate limiting counters: Per-IP and per-customer request rate limits (TTL: 1 minute sliding window)

Key Observations

Beware the thundering herd: When a service window opens and the next customer is called, thousands of clients polling for their position will simultaneously see a state change. Use SSE or WebSockets for push updates to avoid a stampede on the read path. For clients that cannot maintain persistent connections, short-poll with exponential backoff.

4. Data Model

The data model must represent the core entities of a queue management system while remaining flexible enough to accommodate multi-service, multi-location, and priority-based configurations. We use a relational database (PostgreSQL) for transactional consistency of queue state and a time-series store for analytics.

Entity Relationship Diagram

erDiagram LOCATION ||--o{ SERVICE : offers LOCATION ||--o{ QUEUE : manages SERVICE ||--o{ QUEUE : creates QUEUE ||--o{ TICKET : contains TICKET }o--|| CUSTOMER : belongs_to TICKET }o--o| STAFF_MEMBER : served_by TICKET ||--o{ TICKET_EVENT : generates STAFF_MEMBER }o--|| LOCATION : works_at STAFF_MEMBER }o--o{ SERVICE : can_handle QUEUE ||--o{ QUEUE_SNAPSHOT : tracked_by

Core Tables

SQL
CREATE TABLE locations (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name            VARCHAR(255) NOT NULL,
    address         TEXT,
    timezone        VARCHAR(50) NOT NULL DEFAULT 'UTC',
    max_concurrent  INT NOT NULL DEFAULT 100,
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE services (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    location_id     UUID NOT NULL REFERENCES locations(id),
    name            VARCHAR(255) NOT NULL,
    description     TEXT,
    avg_service_min INT NOT NULL DEFAULT 15,
    max_daily       INT NOT NULL DEFAULT 200,
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE queues (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    location_id     UUID NOT NULL REFERENCES locations(id),
    service_id      UUID NOT NULL REFERENCES services(id),
    name            VARCHAR(255) NOT NULL,
    queue_type      VARCHAR(20) NOT NULL DEFAULT 'standard',
    max_size        INT NOT NULL DEFAULT 500,
    status          VARCHAR(20) NOT NULL DEFAULT 'active',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE tickets (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticket_number   SERIAL,
    queue_id        UUID NOT NULL REFERENCES queues(id),
    customer_id     UUID NOT NULL REFERENCES customers(id),
    priority        INT NOT NULL DEFAULT 0,
    status          VARCHAR(20) NOT NULL DEFAULT 'waiting',
    position        INT,
    joined_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    called_at       TIMESTAMPTZ,
    served_at       TIMESTAMPTZ,
    completed_at    TIMESTAMPTZ,
    no_show         BOOLEAN NOT NULL DEFAULT FALSE,
    source          VARCHAR(20) NOT NULL DEFAULT 'online',
    metadata        JSONB DEFAULT '{}'
);

CREATE TABLE ticket_events (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    ticket_id       UUID NOT NULL REFERENCES tickets(id),
    event_type      VARCHAR(30) NOT NULL,
    payload         JSONB DEFAULT '{}',
    created_by      UUID,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE staff_members (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    location_id     UUID NOT NULL REFERENCES locations(id),
    name            VARCHAR(255) NOT NULL,
    role            VARCHAR(50) NOT NULL,
    is_active       BOOLEAN NOT NULL DEFAULT TRUE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE staff_services (
    staff_id        UUID NOT NULL REFERENCES staff_members(id),
    service_id      UUID NOT NULL REFERENCES services(id),
    PRIMARY KEY (staff_id, service_id)
);

CREATE TABLE queue_snapshots (
    id              BIGSERIAL PRIMARY KEY,
    queue_id        UUID NOT NULL REFERENCES queues(id),
    waiting_count   INT NOT NULL,
    avg_wait_sec    INT NOT NULL,
    head_ticket_id  UUID,
    recorded_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Indexing Strategy

SQL
CREATE INDEX idx_tickets_queue_status ON tickets(queue_id, status, priority DESC, joined_at ASC);
CREATE INDEX idx_tickets_customer ON tickets(customer_id, status);
CREATE INDEX idx_ticket_events_ticket ON ticket_events(ticket_id, created_at);
CREATE INDEX idx_queue_snapshots_queue ON queue_snapshots(queue_id, recorded_at DESC);
CREATE INDEX idx_staff_location ON staff_members(location_id, is_active);

The composite index on idx_tickets_queue_status is the most critical index in the entire system. It allows the "serve next" query to efficiently find the highest-priority, earliest-arrived ticket for a given queue that is still in waiting status. The joined_at ASC column ensures FIFO ordering within the same priority level.

5. High-Level Architecture

The system follows a microservices architecture with event-driven communication between components. The core services are the Queue Service (manages queue state), the Notification Service (handles SMS, email, push), the Estimation Service (computes wait times), the Staff Service (manages assignments), and the Analytics Service (aggregates metrics). An API Gateway handles routing, authentication, and rate limiting.

flowchart TB subgraph Clients WebApp["Web App (React)"] MobileApp["Mobile App"] Kiosk["Kiosk Terminal"] QRScan["QR Code Scan"] end subgraph Gateway APIGW["API Gateway\n(Kong / YARP)"] AuthSvc["Auth Service\n(JWT + RBAC)"] end subgraph CoreServices QueueSvc["Queue Service\n(C# / .NET 8)"] NotifySvc["Notification Service"] EstSvc["Estimation Service"] StaffSvc["Staff Service"] AnalyticsSvc["Analytics Service"] end subgraph DataLayer PG[(PostgreSQL\nQueue State)] Redis[(Redis\nCache + PubSub)] Kafka["Apache Kafka\nEvent Stream"] TSDB[(TimescaleDB\nAnalytics)] BlobStore[(Azure Blob\nArchives)] end subgraph External Twilio["Twilio SMS"] FCM["Firebase Push"] SES["AWS SES Email"] end WebApp --> APIGW MobileApp --> APIGW Kiosk --> APIGW QRScan --> APIGW APIGW --> AuthSvc APIGW --> QueueSvc APIGW --> StaffSvc APIGW --> EstSvc QueueSvc --> PG QueueSvc --> Redis QueueSvc --> Kafka NotifySvc --> Kafka NotifySvc --> Twilio NotifySvc --> FCM NotifySvc --> SES EstSvc --> Redis EstSvc --> TSDB StaffSvc --> PG StaffSvc --> Redis AnalyticsSvc --> Kafka AnalyticsSvc --> TSDB

Service Responsibilities

ServiceResponsibilityTechnology
API GatewayRequest routing, rate limiting, SSL termination, authenticationKong / YARP
Auth ServiceJWT token issuance, role-based access control, OAuth integration.NET 8 Identity
Queue ServiceCore queue operations: join, leave, serve, skip, reorder.NET 8 + EF Core
Notification ServiceMulti-channel delivery: SMS, push, email, in-app.NET 8 + Hangfire
Estimation ServiceReal-time wait time calculation using historical and live data.NET 8 + ML.NET
Staff ServiceStaff scheduling, assignment, load balancing, window management.NET 8 + EF Core
Analytics ServiceMetrics aggregation, dashboard data, report generation.NET 8 + TimescaleDB

Event-Driven Communication

All inter-service communication uses Apache Kafka as the event backbone. The Queue Service publishes events for every state transition, and downstream services subscribe to the events they care about. This ensures loose coupling and independent scalability. The key event topics are:

  • queue.ticket.joined — A new customer has entered the queue
  • queue.ticket.position_changed — A customer's position in the queue has changed
  • queue.ticket.called — A customer has been called to a service window
  • queue.ticket.served — Service has begun for a customer
  • queue.ticket.completed — Service has finished for a customer
  • queue.ticket.no_show — A customer did not respond when called
  • queue.ticket.left — A customer voluntarily left the queue
  • queue.staff.available — A staff member has become available
  • queue.snapshot.recorded — Periodic snapshot for analytics and estimation

6. API Design

All APIs follow RESTful conventions with JSON payloads. Authentication is via Bearer JWT tokens. Write operations are idempotent using client-generated UUIDs in the Idempotency-Key header.

Queue Operations

MethodEndpointDescription
POST/api/v1/queues/{queueId}/joinJoin a queue (returns ticket)
DELETE/api/v1/queues/{queueId}/tickets/{ticketId}Leave the queue
GET/api/v1/queues/{queueId}/tickets/{ticketId}/statusGet current position and wait estimate
POST/api/v1/queues/{queueId}/serve-nextStaff: call the next customer
POST/api/v1/queues/{queueId}/tickets/{ticketId}/serveStaff: begin serving a customer
POST/api/v1/queues/{queueId}/tickets/{ticketId}/completeStaff: mark service as complete
POST/api/v1/queues/{queueId}/tickets/{ticketId}/skipStaff: skip a no-show customer
GET/api/v1/queues/{queueId}/stateGet current queue state (dashboard)
POST/api/v1/queues/{queueId}/tickets/{ticketId}/priorityStaff: override priority for a ticket

Staff & Location Operations

MethodEndpointDescription
POST/api/v1/staff/check-inStaff: check in for shift at a location
POST/api/v1/staff/{staffId}/assign-windowAssign staff to a service window
GET/api/v1/locations/{locationId}/dashboardLocation-level dashboard data
GET/api/v1/locations/{locationId}/analyticsHistorical analytics for a location
POST/api/v1/walk-in/check-inRegister a walk-in customer at a kiosk

Join Queue — Request & Response

JSON
// POST /api/v1/queues/{queueId}/join
// Headers: Authorization: Bearer <jwt>, Idempotency-Key: <uuid>

{
    "customerId": "550e8400-e29b-41d4-a716-446655440000",
    "serviceId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "source": "online",
    "metadata": {
        "deviceType": "mobile",
        "appVersion": "2.1.0"
    }
}

// Response: 201 Created
{
    "ticketId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "ticketNumber": 42,
    "queueId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
    "position": 15,
    "estimatedWaitMinutes": 38,
    "joinedAt": "2026-07-10T09:30:00Z",
    "status": "waiting"
}

C# API Controller

C#
[ApiController]
[Route("api/v1/queues/{queueId:guid}")]
[Authorize]
public class QueueController : ControllerBase
{
    private readonly IQueueService _queueService;
    private readonly IEventPublisher _eventPublisher;
    private readonly ILogger<QueueController> _logger;

    public QueueController(
        IQueueService queueService,
        IEventPublisher eventPublisher,
        ILogger<QueueController> logger)
    {
        _queueService = queueService;
        _eventPublisher = eventPublisher;
        _logger = logger;
    }

    [HttpPost("join")]
    [ProducesResponseType(typeof(TicketResponse), StatusCodes.Status201Created)]
    public async Task<ActionResult<TicketResponse>> JoinQueue(
        Guid queueId,
        [FromBody] JoinQueueRequest request,
        [FromHeader(Name = "Idempotency-Key")] Guid? idempotencyKey,
        CancellationToken ct)
    {
        if (idempotencyKey.HasValue)
        {
            var existing = await _queueService.GetExistingTicketAsync(
                idempotencyKey.Value, ct);
            if (existing != null)
                return Ok(existing);
        }

        var ticket = await _queueService.JoinQueueAsync(
            queueId, request.CustomerId, request.Source,
            idempotencyKey ?? Guid.NewGuid(), ct);

        await _eventPublisher.PublishAsync(new TicketJoinedEvent
        {
            TicketId = ticket.Id,
            QueueId = queueId,
            Position = ticket.Position,
            JoinedAt = ticket.JoinedAt
        }, ct);

        _logger.LogInformation(
            "Customer {CustomerId} joined queue {QueueId} at position {Position}",
            request.CustomerId, queueId, ticket.Position);

        return CreatedAtAction(
            nameof(GetTicketStatus),
            new { queueId, ticketId = ticket.Id },
            TicketResponse.From(ticket));
    }

    [HttpPost("serve-next")]
    [Authorize(Roles = "Staff,Admin")]
    public async Task<ActionResult<TicketResponse>> ServeNext(
        Guid queueId,
        [FromQuery] Guid staffMemberId,
        CancellationToken ct)
    {
        var ticket = await _queueService.ServeNextAsync(
            queueId, staffMemberId, ct);

        if (ticket == null)
            return NotFound("No customers in queue");

        await _eventPublisher.PublishAsync(new TicketCalledEvent
        {
            TicketId = ticket.Id,
            QueueId = queueId,
            StaffMemberId = staffMemberId,
            CalledAt = DateTimeOffset.UtcNow
        }, ct);

        return Ok(TicketResponse.From(ticket));
    }
}

7. Virtual Queue Management

Virtual queue management is the heart of the system. Unlike a physical queue where customers physically line up, a virtual queue allows customers to join from anywhere — a mobile phone, a web browser, or a QR code scan — and receive real-time updates about their position without physically waiting in line.

Queue Lifecycle

stateDiagram-v2 [*] --> Joined : Customer joins queue Joined --> Waiting : Enqueued at position N Waiting --> Called : Staff calls next Called --> Serving : Staff begins service Serving --> Completed : Service finished Waiting --> Left : Customer leaves voluntarily Waiting --> Skipped : No-show after call Called --> Skipped : No-show after call Completed --> [*] Left --> [*] Skipped --> [*]

Queue State Machine Implementation

C#
public class QueueTicket
{
    public Guid Id { get; private set; }
    public int TicketNumber { get; private set; }
    public Guid QueueId { get; private set; }
    public Guid CustomerId { get; private set; }
    public int Priority { get; private set; }
    public TicketStatus Status { get; private set; }
    public int? Position { get; private set; }
    public DateTimeOffset JoinedAt { get; private set; }
    public DateTimeOffset? CalledAt { get; private set; }
    public DateTimeOffset? ServedAt { get; private set; }
    public DateTimeOffset? CompletedAt { get; private set; }
    public bool NoShow { get; private set; }
    public string Source { get; private set; }

    private readonly List<TicketEvent> _events = new();
    public IReadOnlyList<TicketEvent> Events => _events.AsReadOnly();

    public void Call()
    {
        if (Status != TicketStatus.Waiting)
            throw new InvalidTransitionException(
                Status, TicketStatus.Called);

        Status = TicketStatus.Called;
        CalledAt = DateTimeOffset.UtcNow;
        Position = null;

        _events.Add(TicketEvent.Create(Id, EventType.Called, new
        {
            CalledAt
        }));
    }

    public void BeginService()
    {
        if (Status != TicketStatus.Called)
            throw new InvalidTransitionException(
                Status, TicketStatus.Serving);

        Status = TicketStatus.Serving;
        ServedAt = DateTimeOffset.UtcNow;

        _events.Add(TicketEvent.Create(Id, EventType.Served, new
        {
            ServedAt
        }));
    }

    public void Complete()
    {
        if (Status != TicketStatus.Serving)
            throw new InvalidTransitionException(
                Status, TicketStatus.Completed);

        Status = TicketStatus.Completed;
        CompletedAt = DateTimeOffset.UtcNow;
        Position = null;

        _events.Add(TicketEvent.Create(Id, EventType.Completed, new
        {
            CompletedAt,
            DurationMinutes = (CompletedAt - ServedAt)?.TotalMinutes
        }));
    }

    public void MarkNoShow()
    {
        if (Status != TicketStatus.Called &&
            Status != TicketStatus.Waiting)
            throw new InvalidTransitionException(
                Status, TicketStatus.NoShow);

        Status = TicketStatus.NoShow;
        NoShow = true;
        Position = null;

        _events.Add(TicketEvent.Create(Id, EventType.NoShow, new
        {
            CalledAt
        }));
    }

    public void Leave()
    {
        if (Status != TicketStatus.Waiting)
            throw new InvalidTransitionException(
                Status, TicketStatus.Left);

        Status = TicketStatus.Left;
        Position = null;

        _events.Add(TicketEvent.Create(Id, EventType.Left));
    }
}

Core Queue Service

C#
public class QueueService : IQueueService
{
    private readonly AppDbContext _db;
    private readonly IDistributedCache _cache;
    private readonly IWaitTimeEstimator _estimator;

    public async Task<QueueTicket> JoinQueueAsync(
        Guid queueId, Guid customerId, string source,
        Guid idempotencyKey, CancellationToken ct)
    {
        await using var transaction = await _db.Database
            .BeginTransactionSerializableAsync(ct);

        try
        {
            var queue = await _db.Queues
                .FirstOrDefaultAsync(q => q.Id == queueId &&
                    q.Status == QueueStatus.Active, ct)
                ?? throw new QueueNotFoundException(queueId);

            var nextPosition = await _db.Tickets
                .Where(t => t.QueueId == queueId &&
                    t.Status == TicketStatus.Waiting)
                .MaxAsync(t => (int?)t.Position) ?? 0;

            var ticket = new QueueTicket
            {
                Id = Guid.NewGuid(),
                QueueId = queueId,
                CustomerId = customerId,
                Source = source,
                Position = nextPosition + 1,
                Status = TicketStatus.Waiting,
                JoinedAt = DateTimeOffset.UtcNow,
                Metadata = new Dictionary<string, object>
                {
                    ["idempotencyKey"] = idempotencyKey
                }
            };

            _db.Tickets.Add(ticket);

            var snapshot = new QueueSnapshot
            {
                QueueId = queueId,
                WaitingCount = nextPosition + 1,
                HeadTicketId = await GetHeadTicketIdAsync(
                    queueId, ct),
                RecordedAt = DateTimeOffset.UtcNow
            };
            _db.QueueSnapshots.Add(snapshot);

            await _db.SaveChangesAsync(ct);
            await transaction.CommitAsync(ct);

            await _cache.RemoveAsync($"queue:{queueId}:state");

            return ticket;
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }

    public async Task<QueueTicket?> ServeNextAsync(
        Guid queueId, Guid staffMemberId, CancellationToken ct)
    {
        await using var transaction = await _db.Database
            .BeginTransactionSerializableAsync(ct);

        try
        {
            var nextTicket = await _db.Tickets
                .Where(t => t.QueueId == queueId &&
                    t.Status == TicketStatus.Waiting)
                .OrderByDescending(t => t.Priority)
                .ThenBy(t => t.JoinedAt)
                .FirstOrDefaultAsync(ct);

            if (nextTicket == null) return null;

            nextTicket.Call();

            await _db.SaveChangesAsync(ct);
            await transaction.CommitAsync(ct);

            await _cache.RemoveAsync($"queue:{queueId}:state");

            return nextTicket;
        }
        catch
        {
            await transaction.RollbackAsync(ct);
            throw;
        }
    }

    private async Task<Guid?> GetHeadTicketIdAsync(
        Guid queueId, CancellationToken ct)
    {
        return await _db.Tickets
            .Where(t => t.QueueId == queueId &&
                t.Status == TicketStatus.Waiting)
            .OrderBy(t => t.JoinedAt)
            .Select(t => t.Id)
            .FirstOrDefaultAsync(ct);
    }
}
Serializable Isolation: Queue operations use serializable transaction isolation to prevent race conditions where two concurrent "serve next" calls might hand out the same ticket. While this is the safest approach, it trades some throughput for correctness. For extreme scale, consider optimistic concurrency with version columns as an alternative.

Kiosk Offline Mode

Physical kiosk terminals at service locations must continue functioning during network outages. The kiosk runs a local SQLite database that caches the last known queue state. When the network is restored, the kiosk synchronizes its locally created tickets with the central server using a conflict resolution strategy that favors server state for position but preserves locally created tickets.

C#
public class KioskSyncService
{
    private readonly LocalKioskDb _localDb;
    private readonly IQueueApiClient _remoteApi;
    private readonly ILogger<KioskSyncService> _logger;

    public async Task<SyncResult> SynchronizeAsync(
        Guid kioskId, CancellationToken ct)
    {
        var localTickets = await _localDb
            .GetUnsyncedTicketsAsync(ct);

        var result = new SyncResult();

        foreach (var ticket in localTickets)
        {
            try
            {
                var remoteTicket = await _remoteApi
                    .CreateTicketAsync(ticket.ToRequest(), ct);

                await _localDb.MarkSyncedAsync(
                    ticket.Id, remoteTicket.TicketId, ct);

                result.SuccessCount++;
            }
            catch (DuplicateTicketException)
            {
                await _localDb.MarkDuplicateAsync(
                    ticket.Id, ct);
                result.DuplicateCount++;

                _logger.LogWarning(
                    "Duplicate ticket detected during sync: {LocalId}",
                    ticket.Id);
            }
        }

        var remoteState = await _remoteApi
            .GetQueueStateAsync(kioskId, ct);
        await _localDb.UpdateQueueStateAsync(
            remoteState, ct);

        return result;
    }
}

8. Priority & Fairness

Priority management is essential in real-world queue systems. A hospital must see a critical patient before someone with a minor cold. A government office may prioritize elderly citizens or people with disabilities. A bank may offer VIP service to premium customers. However, priority must be balanced against fairness — regular customers should not be starved indefinitely.

Priority Levels

LevelValueDescriptionExample
Critical100Immediate service requiredEmergency medical patient
High75Expedited serviceElderly, disabled, pregnant
VIP50Premium customer tierBank premium account holder
Appointment25Pre-scheduled slotOnline appointment booked
Regular0Standard serviceWalk-in or online join
Low-25Deprioritized (with aging)Repeated no-show customer

Aging Algorithm

To prevent starvation of lower-priority tickets, we implement an aging algorithm that gradually increases the effective priority of long-waiting tickets. Every 5 minutes a ticket remains in the waiting state, its effective priority increases by 1. This means a regular customer (priority 0) who has waited for 60 minutes gains priority 12, eventually surpassing a VIP customer (priority 50) who just joined if they wait long enough (approximately 250 minutes or about 4 hours).

C#
public class PriorityQueueCalculator : IPriorityCalculator
{
    private readonly int _agingIncrement;
    private readonly TimeSpan _agingInterval;

    public PriorityQueueCalculator(
        int agingIncrement = 1,
        TimeSpan? agingInterval = null)
    {
        _agingIncrement = agingIncrement;
        _agingInterval = agingInterval ?? TimeSpan.FromMinutes(5);
    }

    public int CalculateEffectivePriority(QueueTicket ticket)
    {
        var waitDuration = DateTimeOffset.UtcNow - ticket.JoinedAt;
        var agingTicks = (int)(waitDuration.TotalMinutes /
            _agingInterval.TotalMinutes);

        var agedPriority = ticket.Priority +
            (agingTicks * _agingIncrement);

        return Math.Min(agedPriority, MaxPriorityCap);
    }

    public List<QueueTicket> GetSortedWaitingList(
        List<QueueTicket> waitingTickets)
    {
        return waitingTickets
            .OrderByDescending(t =>
                CalculateEffectivePriority(t))
            .ThenBy(t => t.JoinedAt)
            .ToList();
    }

    private const int MaxPriorityCap = 200;

    public bool ShouldPromoteOverVIP(QueueTicket ticket)
    {
        return CalculateEffectivePriority(ticket) > 50;
    }
}

Fairness Configuration

JSON
{
    "priorityRules": {
        "agingEnabled": true,
        "agingIncrement": 1,
        "agingIntervalMinutes": 5,
        "maxPriorityCap": 200,
        "vipBoostMinutes": 30,
        "criticalBypassEnabled": true,
        "starvationThresholdMinutes": 120,
        "maxWaitBeforeAutoPromote": 240
    },
    "fairnessPolicy": {
        "enforceMaxWaitMinutes": 240,
        "autoUpgradeAfterMaxWait": true,
        "notifyCustomerBeforeAutoUpgrade": true,
        "repeatedNoShowCooldownHours": 24,
        "maxConsecutiveSkipsBeforeCooldown": 3
    }
}
Interview Tip: Be prepared to discuss the trade-off between strict priority and fairness. Strict priority leads to starvation; aging prevents starvation but reduces the benefit of priority levels. The optimal parameters depend on the business domain — a hospital will tolerate longer starvation windows than a retail store.

9. Multi-Service Queues

In a real-world location, multiple services are offered simultaneously. A government office may have windows for passport applications, driver license renewals, and general inquiries. Each service may have its own queue, or services may share a pool of staff who can handle multiple service types. The system must support both models.

Queue Models

ModelDescriptionProsCons
Separate QueuesEach service has its own dedicated queueSimple, predictable wait times per serviceCan lead to unbalanced utilization
Shared QueueAll services share one queue, staff handle any typeBetter utilization, simpler customer experienceComplex routing, variable service times
HybridDedicated queues with overflow to shared poolBalance of efficiency and simplicityMost complex to implement

Service-Aware Queue Manager

C#
public class MultiServiceQueueManager : IMultiServiceQueueManager
{
    private readonly AppDbContext _db;
    private readonly IStaffAvailabilityService _staffService;

    public async Task<ServiceRoutingResult> RouteCustomerAsync(
        Guid locationId, Guid serviceId,
        CancellationToken ct)
    {
        var service = await _db.Services
            .FindAsync(serviceId)
            ?? throw new ServiceNotFoundException(serviceId);

        var availableStaff = await _staffService
            .GetAvailableForServiceAsync(serviceId, ct);

        if (availableStaff.Any())
        {
            var dedicatedQueue = await GetOrCreateDedicatedQueueAsync(
                locationId, serviceId, ct);

            return new ServiceRoutingResult
            {
                QueueId = dedicatedQueue.Id,
                Strategy = RoutingStrategy.Dedicated,
                EstimatedWaitMinutes = await EstimateWaitAsync(
                    dedicatedQueue.Id, ct)
            };
        }

        var sharedStaff = await _staffService
            .GetAvailableMultiSkillStaffAsync(
                locationId, ct);

        if (sharedStaff.Any())
        {
            var sharedQueue = await GetOrCreateSharedQueueAsync(
                locationId, ct);

            return new ServiceRoutingResult
            {
                QueueId = sharedQueue.Id,
                Strategy = RoutingStrategy.Shared,
                EstimatedWaitMinutes = await EstimateWaitAsync(
                    sharedQueue.Id, ct)
            };
        }

        var allQueues = await _db.Queues
            .Where(q => q.LocationId == locationId &&
                q.Status == QueueStatus.Active)
            .ToListAsync(ct);

        var bestQueue = allQueues
            .OrderBy(q => q.CurrentWaitingCount)
            .First();

        return new ServiceRoutingResult
        {
            QueueId = bestQueue.Id,
            Strategy = RoutingStrategy.Overflow,
            EstimatedWaitMinutes = await EstimateWaitAsync(
                bestQueue.Id, ct)
        };
    }

    public async Task<List<ServiceMetrics>> GetServiceMetricsAsync(
        Guid locationId, CancellationToken ct)
    {
        return await _db.Queues
            .Where(q => q.LocationId == locationId)
            .Select(q => new ServiceMetrics
            {
                ServiceId = q.ServiceId,
                ServiceName = q.Service.Name,
                WaitingCount = q.Tickets
                    .Count(t => t.Status == TicketStatus.Waiting),
                AverageWaitMinutes = q.Snapshots
                    .Where(s => s.RecordedAt >
                        DateTimeOffset.UtcNow.AddMinutes(-30))
                    .Average(s => (double?)s.AvgWaitSec) / 60.0 ?? 0,
                ThroughputPerHour = q.Tickets
                    .Count(t => t.Status == TicketStatus.Completed &&
                        t.CompletedAt >
                            DateTimeOffset.UtcNow.AddHours(-1))
            })
            .ToListAsync(ct);
    }
}

10. Real-Time Wait Time Estimation

Accurate wait time estimation is one of the most valued features for customers. An inaccurate estimate erodes trust and leads to complaints. We use a hybrid approach combining historical averages with real-time adjustments.

Estimation Algorithm

flowchart TD A[Customer asks for wait time] --> B{Staff available
right now?} B -- Yes --> C[Simple calculation
position × avg_service_time] B -- No --> D{Next shift
starts soon?} D -- Yes, within 15 min --> E[Add partial shift
overlap factor] D -- No --> F[Use historical data
for similar time/day] C --> G[Apply confidence
adjustment factor] E --> G F --> G G --> H[Return estimate
with confidence range]

Estimation Service

C#
public class WaitTimeEstimator : IWaitTimeEstimator
{
    private readonly AppDbContext _db;
    private readonly IDistributedCache _cache;
    private readonly IStaffScheduleService _scheduleService;

    public async Task<WaitTimeEstimate> EstimateAsync(
        Guid queueId, int position,
        CancellationToken ct)
    {
        var cacheKey = $"wait:{queueId}:{position}";
        var cached = await cache.GetStringAsync(cacheKey, ct);
        if (cached != null)
            return JsonSerializer.Deserialize<WaitTimeEstimate>(cached)!;

        var queue = await _db.Queues
            .Include(q => q.Service)
            .FirstOrDefaultAsync(q => q.Id == queueId, ct)
            ?? throw new QueueNotFoundException(queueId);

        var historicalAvg = await GetHistoricalAverageAsync(
            queue.ServiceId, ct);

        var currentThroughput = await GetCurrentThroughputAsync(
            queueId, ct);

        var staffAvailability = await _scheduleService
            .GetUpcomingAvailabilityAsync(queueId, ct);

        var baseEstimate = position * historicalAvg;

        var adjustedEstimate = ApplyAdjustments(baseEstimate,
            currentThroughput, staffAvailability);

        var confidence = CalculateConfidence(
            position, historicalAvg, staffAvailability);

        var result = new WaitTimeEstimate
        {
            EstimatedMinutes = Math.Max(1,
                (int)Math.Round(adjustedEstimate)),
            ConfidenceLow = (int)Math.Round(
                adjustedEstimate * 0.7),
            ConfidenceHigh = (int)Math.Round(
                adjustedEstimate * 1.4),
            ConfidencePercent = confidence,
            CalculatedAt = DateTimeOffset.UtcNow,
            BasedOnSamples = await GetSampleCountAsync(
                queue.ServiceId, ct)
        };

        await cache.SetStringAsync(cacheKey,
            JsonSerializer.Serialize(result),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    TimeSpan.FromSeconds(30)
            }, ct);

        return result;
    }

    private async Task<TimeSpan> GetHistoricalAverageAsync(
        Guid serviceId, CancellationToken ct)
    {
        var recentStats = await _db.CompletedTickets
            .Where(t => t.ServiceId == serviceId &&
                t.CompletedAt > DateTimeOffset.UtcNow.AddDays(-30))
            .GroupBy(t => t.CompletedAt.Hour)
            .Select(g => new
            {
                Hour = g.Key,
                AvgMinutes = g.Average(t =>
                    (t.CompletedAt - t.ServedAt).TotalMinutes)
            })
            .ToListAsync(ct);

        var currentHour = DateTimeOffset.UtcNow.Hour;
        var matching = recentStats
            .FirstOrDefault(s => s.Hour == currentHour);

        return TimeSpan.FromMinutes(
            matching?.AvgMinutes ?? recentStats
                .Average(s => s.AvgMinutes));
    }

    private double ApplyAdjustments(
        double baseEstimate,
        double currentThroughput,
        StaffAvailabilityWindow[] staffAvailability)
    {
        var activeStaff = staffAvailability
            .Count(s => s.IsCurrentlyActive);

        var staffFactor = activeStaff > 0
            ? 1.0 / Math.Min(activeStaff, 4)
            : double.MaxValue;

        var throughputFactor = currentThroughput > 0
            ? Math.Min(1.2, 1.0 / currentThroughput)
            : 1.0;

        return baseEstimate * staffFactor * throughputFactor;
    }

    private int CalculateConfidence(
        int position,
        TimeSpan avgServiceTime,
        StaffAvailabilityWindow[] availability)
    {
        var baseConfidence = 70;

        if (availability.Any(s => s.IsCurrentlyActive))
            baseConfidence += 15;

        if (position <= 5)
            baseConfidence += 10;
        else if (position > 20)
            baseConfidence -= 15;

        return Math.Clamp(baseConfidence, 30, 95);
    }
}

Estimation Accuracy Metrics

MetricTargetDescription
Mean Absolute Error< 5 minutesAverage difference between estimated and actual wait
Within 20% Accuracy> 70%Percentage of estimates within 20% of actual
Underestimate Rate< 25%Percentage of estimates that are shorter than actual
Estimation Latency (P99)< 50msTime to compute an estimate
Sample Size for Confidence> 30/dayMinimum historical data points per service

11. SMS & Notification Updates

Keeping customers informed about their queue status is critical to reducing perceived wait time and preventing walk-aways. The notification service supports SMS, email, push notifications, and in-app messages. Notifications are event-driven, triggered by queue state changes, and delivered asynchronously to ensure the queue path is never blocked by delivery latency.

Notification Types

EventChannelMessageTiming
Joined QueueSMS + Push"You are #42 in queue. Estimated wait: 38 minutes."Immediate
Position UpdatePush"Your position improved to #30. About 28 minutes remaining."Every 5 positions
5 Min WarningSMS + Push"Please prepare! You will be called in approximately 5 minutes."When ~5 min remaining
CalledSMS + Push + Ring"Please proceed to Window 7. You have 5 minutes."Immediate
No ResponseSMS"We missed you at Window 7. Please check in at the kiosk."After 3 min
SkippedSMS"You have been moved to the end of the queue."Immediate
Service CompletePush + Email"Your service is complete. Thank you!"Immediate

Notification Service

C#
public class NotificationService : INotificationService
{
    private readonly ISmsProvider _smsProvider;
    private readonly IPushProvider _pushProvider;
    private readonly IEmailProvider _emailProvider;
    private readonly INotificationPreferenceStore _prefStore;
    private readonly ILogger<NotificationService> _logger;

    public async Task SendTicketNotificationAsync(
        QueueTicket ticket, NotificationType type,
        CancellationToken ct)
    {
        var preferences = await _prefStore
            .GetPreferencesAsync(ticket.CustomerId, ct);

        if (!preferences.IsEnabled(type))
            return;

        var message = await ComposeMessageAsync(
            ticket, type, ct);

        var channels = preferences.GetChannels(type);

        var tasks = new List<Task>();

        if (channels.Contains(NotificationChannel.Sms) &&
            preferences.PhoneNumber != null)
        {
            tasks.Add(SendWithRetryAsync(() =>
                _smsProvider.SendAsync(
                    preferences.PhoneNumber,
                    message.Body,
                    ct),
                maxRetries: 3));
        }

        if (channels.Contains(NotificationChannel.Push) &&
            preferences.DeviceToken != null)
        {
            tasks.Add(SendWithRetryAsync(() =>
                _pushProvider.SendAsync(
                    preferences.DeviceToken,
                    message.Title,
                    message.Body,
                    message.Data,
                    ct),
                maxRetries: 2));
        }

        if (channels.Contains(NotificationChannel.Email) &&
            preferences.Email != null)
        {
            tasks.Add(SendWithRetryAsync(() =>
                _emailProvider.SendAsync(
                    preferences.Email,
                    message.Title,
                    message.HtmlBody,
                    ct),
                maxRetries: 2));
        }

        await Task.WhenAll(tasks);

        _logger.LogInformation(
            "Notification {Type} sent for ticket {TicketId} " +
            "via {Channels}",
            type, ticket.Id,
            string.Join(",", channels));
    }

    private async Task<T> SendWithRetryAsync<T>(
        Func<Task<T>> operation,
        int maxRetries)
    {
        for (int i = 0; i <= maxRetries; i++)
        {
            try
            {
                return await operation();
            }
            catch (Exception ex) when (i < maxRetries)
            {
                var delay = TimeSpan.FromSeconds(
                    Math.Pow(2, i));
                _logger.LogWarning(ex,
                    "Notification delivery failed, " +
                    "retrying in {Delay}s",
                    delay.TotalSeconds);
                await Task.Delay(delay);
            }
        }
        throw new NotificationDeliveryException(
            "Max retries exceeded");
    }
}

Notification Delivery Metrics

MetricSMSPushEmail
Delivery Rate> 98%> 95%> 99%
Latency (P95)< 3 seconds< 1 second< 30 seconds
Cost per Message$0.0075$0.0005$0.0001
Opt-out Rate< 5%< 8%< 15%

12. Walk-In vs Online Queue

A critical design decision is how to merge walk-in customers (who physically arrive at the location) with online customers (who join remotely). There are several strategies, each with different trade-offs.

Merging Strategies

StrategyDescriptionFairnessComplexity
FIFO MergeWalk-ins get next position after latest online joinerHighLow
Time-Slot ReservationWalk-ins scan QR, get a time slot based on current queue depthHighMedium
Parallel QueuesSeparate walk-in and online queues, serve alternatelyMediumLow
Weighted MixConfigurable ratio: e.g., serve 2 walk-ins for every 3 onlineConfigurableHigh

Walk-In Check-In Flow

C#
public class WalkInService : IWalkInService
{
    private readonly IQueueService _queueService;
    private readonly IQRCodeGenerator _qrGen;
    private readonly INotificationService _notification;

    public async Task<WalkInResult> CheckInAsync(
        Guid locationId, string? phoneEmail,
        CancellationToken ct)
    {
        var location = await _db.Locations
            .FindAsync(locationId)
            ?? throw new LocationNotFoundException(locationId);

        var primaryQueue = await _db.Queues
            .Where(q => q.LocationId == locationId &&
                q.QueueType == QueueType.Standard &&
                q.Status == QueueStatus.Active)
            .OrderBy(q => q.CurrentWaitingCount)
            .FirstOrDefaultAsync(ct)
            ?? throw new NoActiveQueueException(locationId);

        var customer = await EnsureCustomerAsync(
            phoneEmail, ct);

        var ticket = await _queueService.JoinQueueAsync(
            primaryQueue.Id,
            customer.Id,
            source: "walk_in",
            idempotencyKey: Guid.NewGuid(),
            ct);

        var qrCode = await _qrGen.GenerateAsync(
            ticket.Id.ToString(),
            primaryQueue.Id.ToString(),
            ct);

        if (customer.PhoneNumber != null)
        {
            await _notification.SendTicketNotificationAsync(
                ticket, NotificationType.JoinedQueue, ct);
        }

        return new WalkInResult
        {
            TicketId = ticket.Id,
            TicketNumber = ticket.TicketNumber,
            Position = ticket.Position,
            EstimatedWaitMinutes = ticket.EstimatedWaitMinutes,
            QRCodeImage = qrCode,
            Instructions = $"Show this QR code at any kiosk " +
                $"or window. You are #{ticket.TicketNumber}."
        };
    }
}
Best Practice: Always provide walk-in customers with a QR code they can scan to check their position on their phone. This reduces the need for physical display boards and enables the notification system to keep them updated even if they step outside the building.

13. Staff Assignment & Routing

Efficient staff assignment is key to maximizing throughput and minimizing wait times. The system must track which staff members are available, which services they can handle, and how to optimally match them to incoming customers.

Assignment Models

flowchart LR A[Customer
waiting] --> B{Staff
matching} B -->|Exact Match| C[Staff trained
for this service] B -->|Cross-Train| D[Staff who can
handle multiple services] B -->|No Match| E[Queue for
next available] C --> F[Assign to
Window] D --> F E --> G[Hold in
queue]

Staff Routing Service

C#
public class StaffRoutingService : IStaffRoutingService
{
    private readonly AppDbContext _db;
    private readonly ILoadBalancer _loadBalancer;

    public async Task<StaffAssignment?> FindBestStaffAsync(
        Guid queueId, Guid serviceId,
        CancellationToken ct)
    {
        var eligibleStaff = await _db.StaffMembers
            .Where(s => s.Location.Queues
                .Any(q => q.Id == queueId) &&
                s.IsActive &&
                s.CurrentStatus == StaffStatus.Available &&
                s.Services.Any(svc =>
                    svc.Id == serviceId))
            .Include(s => s.CurrentWindow)
            .ToListAsync(ct);

        if (!eligibleStaff.Any())
        {
            var crossTrained = await _db.StaffMembers
                .Where(s => s.Location.Queues
                    .Any(q => q.Id == queueId) &&
                    s.IsActive &&
                    s.CurrentStatus == StaffStatus.Available)
                .Include(s => s.Services)
                .ToListAsync(ct);

            eligibleStaff = crossTrained
                .Where(s => s.Services.Count > 1)
                .ToList();
        }

        if (!eligibleStaff.Any())
            return null;

        var scored = eligibleStaff
            .Select(s => new
            {
                Staff = s,
                Score = CalculateAssignmentScore(s)
            })
            .OrderByDescending(x => x.Score)
            .ToList();

        var best = scored.First();

        return new StaffAssignment
        {
            StaffMemberId = best.Staff.Id,
            WindowNumber = best.Staff.CurrentWindow?.Number,
            Score = best.Score,
            Reason = GetAssignmentReason(best.Staff, best.Score)
        };
    }

    private double CalculateAssignmentScore(StaffMember staff)
    {
        var skillScore = staff.Services.Count * 10.0;
        var idleTimeScore = staff.LastServedAt.HasValue
            ? (DateTimeOffset.UtcNow - staff.LastServedAt.Value)
                .TotalMinutes * 0.5
            : 20.0;
        var todayLoad = staff.TicketsServedToday * -0.3;

        return skillScore + idleTimeScore + todayLoad;
    }
}

Window Management

Each physical service window is tracked as a resource that can be opened, closed, or reassigned. The system maintains a real-time map of which staff member is at which window and which service that window is currently handling.

C#
public class WindowManager : IWindowManager
{
    public async Task<WindowStatus> OpenWindowAsync(
        Guid windowId, Guid staffMemberId,
        Guid serviceId, CancellationToken ct)
    {
        var window = await _db.Windows
            .FindAsync(windowId)
            ?? throw new WindowNotFoundException(windowId);

        var staff = await _db.StaffMembers
            .FindAsync(staffMemberId)
            ?? throw new StaffNotFoundException(staffMemberId);

        window.Status = WindowStatus.Open;
        window.CurrentStaffId = staffMemberId;
        window.CurrentServiceId = serviceId;
        window.OpenedAt = DateTimeOffset.UtcNow;

        staff.CurrentStatus = StaffStatus.Busy;
        staff.CurrentWindowId = windowId;

        await _db.SaveChangesAsync(ct);

        await _cache.SetStringAsync(
            $"window:{windowId}:state",
            JsonSerializer.Serialize(new
            {
                WindowId = windowId,
                StaffName = staff.Name,
                ServiceId = serviceId,
                OpenedAt = window.OpenedAt
            }),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    TimeSpan.FromHours(8)
            }, ct);

        return WindowStatus.Open;
    }

    public async Task<List<WindowOverview>>
        GetWindowOverviewAsync(Guid locationId,
            CancellationToken ct)
    {
        return await _db.Windows
            .Where(w => w.LocationId == locationId)
            .Select(w => new WindowOverview
            {
                WindowId = w.Id,
                Number = w.Number,
                Status = w.Status,
                StaffName = w.CurrentStaff != null
                    ? w.CurrentStaff.Name
                    : "Unassigned",
                CurrentService = w.CurrentService != null
                    ? w.CurrentService.Name
                    : "N/A",
                CurrentCustomer = w.CurrentTicket != null
                    ? w.CurrentTicket.Customer.Name
                    : null,
                OpenedAt = w.OpenedAt,
                CustomersServedToday = w.Tickets
                    .Count(t =>
                        t.CompletedAt.Value.Date ==
                            DateTimeOffset.UtcNow.Date)
            })
            .ToListAsync(ct);
    }
}

14. Dashboard & Analytics

The analytics subsystem aggregates queue events into actionable insights for managers, operations teams, and business analysts. Dashboards provide real-time visibility into queue health, while historical reports support capacity planning and staffing optimization.

Key Metrics

MetricDescriptionCalculation
Average Wait TimeMean time from join to serveAvg(served_at - joined_at) for completed tickets
Median Wait Time50th percentile waitPercentile(served_at - joined_at, 0.5)
90th Percentile WaitWait time for 90% of customersPercentile(served_at - joined_at, 0.9)
ThroughputCustomers served per hourCount(completed tickets) in last 60 min
Average Service TimeTime spent being servedAvg(completed_at - served_at)
No-Show RatePercentage of no-showsCount(no_show) / Count(called) × 100
Abandonment RateLeft queue before being calledCount(left) / Count(joined) × 100
Staff UtilizationPercentage of time staff is servingTotal service time / shift duration × 100
Peak HourBusiest hour of the dayHour with max joined count
Current Queue DepthCustomers currently waitingCount(status = waiting)

Analytics Data Pipeline

C#
public class AnalyticsAggregator : IAnalyticsAggregator
{
    private readonly TimescaleDbContext _tsdb;
    private readonly AppDbContext _db;

    public async Task<DashboardData> GetRealTimeDashboardAsync(
        Guid locationId, CancellationToken ct)
    {
        var now = DateTimeOffset.UtcNow;
        var today = now.Date;

        var activeQueues = await _db.Queues
            .Where(q => q.LocationId == locationId &&
                q.Status == QueueStatus.Active)
            .ToListAsync(ct);

        var queueStats = new List<QueueStat>();
        foreach (var queue in activeQueues)
        {
            var waiting = await _db.Tickets
                .CountAsync(t => t.QueueId == queue.Id &&
                    t.Status == TicketStatus.Waiting, ct);

            var serving = await _db.Tickets
                .CountAsync(t => t.QueueId == queue.Id &&
                    t.Status == TicketStatus.Serving, ct);

            var servedToday = await _db.Tickets
                .CountAsync(t => t.QueueId == queue.Id &&
                    t.Status == TicketStatus.Completed &&
                    t.CompletedAt >= today, ct);

            var avgWait = await _db.Tickets
                .Where(t => t.QueueId == queue.Id &&
                    t.Status == TicketStatus.Completed &&
                    t.ServedAt >= today)
                .AverageAsync(t =>
                    t.ServedAt.HasValue && t.JoinedAt.HasValue
                        ? (double?)(t.ServedAt.Value -
                            t.JoinedAt.Value).TotalMinutes
                        : null, ct) ?? 0;

            queueStats.Add(new QueueStat
            {
                QueueName = queue.Name,
                WaitingCount = waiting,
                ServingCount = serving,
                ServedToday = servedToday,
                AverageWaitMinutes = Math.Round(avgWait, 1)
            });
        }

        var activeStaff = await _db.StaffMembers
            .CountAsync(s => s.LocationId == locationId &&
                s.CurrentStatus == StaffStatus.Busy, ct);

        var totalToday = await _db.Tickets
            .CountAsync(t => t.LocationId == locationId &&
                t.CompletedAt >= today, ct);

        return new DashboardData
        {
            LocationId = locationId,
            Timestamp = now,
            Queues = queueStats,
            TotalWaiting = queueStats.Sum(q => q.WaitingCount),
            TotalServing = queueStats.Sum(q => q.ServingCount),
            ActiveStaffCount = activeStaff,
            ServedToday = totalToday,
            OverallAverageWaitMinutes = queueStats.Any()
                ? queueStats.Average(q => q.AverageWaitMinutes)
                : 0
        };
    }
}

Historical Report Generation

C#
public class ReportGenerator : IReportGenerator
{
    public async Task<DailyReport> GenerateDailyReportAsync(
        Guid locationId, DateTimeOffset date,
        CancellationToken ct)
    {
        var startOfDay = date.Date;
        var endOfDay = startOfDay.AddDays(1);

        var tickets = await _db.Tickets
            .Where(t => t.LocationId == locationId &&
                t.JoinedAt >= startOfDay &&
                t.JoinedAt < endOfDay)
            .ToListAsync(ct);

        var completed = tickets
            .Where(t => t.Status == TicketStatus.Completed)
            .ToList();

        var waitTimes = completed
            .Where(t => t.ServedAt.HasValue)
            .Select(t => (t.ServedAt!.Value -
                t.JoinedAt).TotalMinutes)
            .ToList();

        var serviceTimes = completed
            .Where(t => t.CompletedAt.HasValue &&
                t.ServedAt.HasValue)
            .Select(t => (t.CompletedAt!.Value -
                t.ServedAt!.Value).TotalMinutes)
            .ToList();

        return new DailyReport
        {
            LocationId = locationId,
            Date = startOfDay,
            TotalJoined = tickets.Count,
            TotalServed = completed.Count,
            TotalNoShow = tickets.Count(t => t.NoShow),
            TotalLeft = tickets.Count(t =>
                t.Status == TicketStatus.Left),
            AverageWaitMinutes = waitTimes.Any()
                ? waitTimes.Average() : 0,
            MedianWaitMinutes = waitTimes.Any()
                ? waitTimes.OrderBy(x => x)
                    .ElementAt(waitTimes.Count / 2) : 0,
            P90WaitMinutes = waitTimes.Any()
                ? waitTimes.OrderBy(x => x)
                    .ElementAt((int)(waitTimes.Count * 0.9)) : 0,
            AverageServiceMinutes = serviceTimes.Any()
                ? serviceTimes.Average() : 0,
            NoShowRate = tickets.Count > 0
                ? (double)tickets.Count(t => t.NoShow) /
                    tickets.Count * 100 : 0,
            AbandonmentRate = tickets.Count > 0
                ? (double)tickets.Count(t =>
                    t.Status == TicketStatus.Left) /
                    tickets.Count * 100 : 0,
            HourlyBreakdown = GetHourlyBreakdown(tickets)
        };
    }
}

15. Multi-Location Support

Enterprise deployments span hundreds or thousands of physical locations, each with its own queues, staff, and operating hours. The system must support independent operation per location while providing centralized management and reporting.

Federation Architecture

flowchart TB subgraph Central AdminPortal["Admin Portal"] CentralDB[(Central PostgreSQL
Location Registry)] AnalyticsAPI["Analytics API"] end subgraph Region_A["Region A"] Loc1["Location 1\n(Own DB + Redis)"] Loc2["Location 2\n(Own DB + Redis)"] end subgraph Region_B["Region B"] Loc3["Location 3\n(Own DB + Redis)"] Loc4["Location 4\n(Own DB + Redis)"] end AdminPortal --> CentralDB AnalyticsAPI --> CentralDB CentralDB -->|Sync config| Loc1 CentralDB -->|Sync config| Loc2 CentralDB -->|Sync config| Loc3 CentralDB -->|Sync config| Loc4 Loc1 -->|Aggregate analytics| AnalyticsAPI Loc2 -->|Aggregate analytics| AnalyticsAPI Loc3 -->|Aggregate analytics| AnalyticsAPI Loc4 -->|Aggregate analytics| AnalyticsAPI

Location Configuration

C#
public class LocationConfiguration
{
    public Guid LocationId { get; set; }
    public string Name { get; set; }
    public string Timezone { get; set; }
    public OperatingHours OperatingHours { get; set; }
    public List<ServiceConfig> Services { get; set; }
    public QueuePolicy Policy { get; set; }
    public NotificationConfig Notifications { get; set; }
    public DisplaySettings Display { get; set; }
}

public class OperatingHours
{
    public Dictionary<DayOfWeek, TimeWindow> Hours { get; set; }
    public List<Holiday> Holidays { get; set; }

    public bool IsOpen(DateTimeOffset dateTime)
    {
        var localTime = TimeZoneInfo
            .ConvertTimeBySystemTimeZoneId(
                dateTime, Timezone);

        if (Holidays.Any(h =>
            h.Date == localTime.Date))
            return false;

        if (!Hours.TryGetValue(
            localTime.DayOfWeek, out var window))
            return false;

        return localTime.TimeOfDay >= window.Open &&
            localTime.TimeOfDay <= window.Close;
    }
}

public class QueuePolicy
{
    public int MaxQueueSize { get; set; } = 500;
    public int MaxWaitBeforeAutoSkipMinutes { get; set; } = 30;
    public bool AllowOnlineJoin { get; set; } = true;
    public bool AllowWalkInJoin { get; set; } = true;
    public string DefaultPriorityStrategy { get; set; }
        = "aging";
    public bool EnableSMSNotifications { get; set; } = true;
    public int NotificationBatchSize { get; set; } = 50;
}

Cross-Location Customer Lookup

C#
public class CrossLocationService : ICrossLocationService
{
    private readonly ILocationRegistry _registry;

    public async Task<CustomerLocationResult>
        FindNearestLocationAsync(
            double latitude, double longitude,
            string serviceType,
            CancellationToken ct)
    {
        var locations = await _registry
            .GetActiveLocationsAsync(ct);

        var candidates = locations
            .Where(l =>
                l.Services.Any(s =>
                    s.Type == serviceType) &&
                l.IsOpen(DateTimeOffset.UtcNow))
            .Select(l => new
            {
                Location = l,
                Distance = CalculateDistance(
                    latitude, longitude,
                    l.Latitude, l.Longitude)
            })
            .Where(l => l.Distance <= 50)
            .OrderBy(l => l.Distance)
            .Take(5)
            .ToListAsync();

        return new CustomerLocationResult
        {
            NearbyLocations = candidates.Select(c =>
                new NearbyLocation
                {
                    LocationId = c.Location.Id,
                    Name = c.Location.Name,
                    Address = c.Location.Address,
                    DistanceKm = Math.Round(c.Distance, 1),
                    CurrentWaitMinutes = await EstimateWaitAsync(
                        c.Location.Id, serviceType, ct),
                    AvailableServices = c.Location.Services
                        .Select(s => s.Name).ToList()
                }).ToList()
        };
    }
}

16. Integration with Appointment Systems

Many queue management deployments coexist with appointment scheduling. Customers who booked appointments should be slotted into the queue at the right time without waiting behind walk-ins. The system must handle appointment arrivals, late arrivals, and no-shows.

Appointment Lifecycle

stateDiagram-v2 [*] --> Booked : Customer books appointment Booked --> Confirmed : Within 24h reminder Confirmed --> Arrived : Customer checks in Arrived --> InQueue : Merged into queue InQueue --> Serving : Called to window Serving --> Completed : Service done Booked --> NoShow : Did not arrive Confirmed --> NoShow : Did not arrive Arrived --> LateArrival : After grace period LateArrival --> InQueue : Merged at current position LateArrival --> Rescheduled : Staff decides

Appointment-Aware Queue Service

C#
public class AppointmentQueueService : IAppointmentQueueService
{
    private readonly AppDbContext _db;
    private readonly IQueueService _queueService;

    public async Task<AppointmentCheckinResult>
        CheckInAppointmentAsync(
            Guid appointmentId,
            CancellationToken ct)
    {
        var appointment = await _db.Appointments
            .Include(a => a.Customer)
            .Include(a => a.Service)
            .FirstOrDefaultAsync(a => a.Id == appointmentId, ct)
            ?? throw new AppointmentNotFoundException(
                appointmentId);

        var scheduledTime = appointment.ScheduledTime;
        var now = DateTimeOffset.UtcNow;
        var graceWindow = TimeSpan.FromMinutes(15);

        if (now > scheduledTime.Add(graceWindow))
        {
            return await HandleLateArrivalAsync(
                appointment, ct);
        }

        var queue = await _db.Queues
            .FirstAsync(q =>
                q.ServiceId == appointment.ServiceId &&
                q.LocationId == appointment.LocationId, ct);

        var ticket = await _queueService.JoinQueueAsync(
            queue.Id,
            appointment.CustomerId,
            source: "appointment",
            idempotencyKey: Guid.NewGuid(),
            ct);

        ticket.Priority = 25;
        appointment.Status = AppointmentStatus.Arrived;
        appointment.LinkedTicketId = ticket.Id;

        await _db.SaveChangesAsync(ct);

        return new AppointmentCheckinResult
        {
            TicketId = ticket.Id,
            Position = ticket.Position,
            EstimatedWaitMinutes = Math.Min(5,
                ticket.EstimatedWaitMinutes),
            Message = "Welcome! You have an appointment. " +
                "You will be called shortly."
        };
    }

    private async Task<AppointmentCheckinResult>
        HandleLateArrivalAsync(
            Appointment appointment,
            CancellationToken ct)
    {
        var queue = await _db.Queues
            .FirstAsync(q =>
                q.ServiceId == appointment.ServiceId &&
                q.LocationId == appointment.LocationId, ct);

        var ticket = await _queueService.JoinQueueAsync(
            queue.Id,
            appointment.CustomerId,
            source: "appointment_late",
            idempotencyKey: Guid.NewGuid(), ct);

        appointment.Status = AppointmentStatus.LateArrival;
        appointment.LinkedTicketId = ticket.Id;

        await _db.SaveChangesAsync(ct);

        return new AppointmentCheckinResult
        {
            TicketId = ticket.Id,
            Position = ticket.Position,
            EstimatedWaitMinutes = ticket.EstimatedWaitMinutes,
            Message = "You have arrived late for your " +
                "appointment. You have been added to " +
                "the queue."
        };
    }
}

Appointment Slot Configuration

FieldDescriptionExample
Slot DurationLength of each appointment slot15 minutes
Buffer TimeGap between slots for prep5 minutes
Grace PeriodMinutes past slot before marked late15 minutes
Max Slots Per HourCapacity constraint3 per window
Overbooking RatePercentage of extra slots to fill no-show gaps10%
Advance Booking WindowHow far ahead customers can book30 days

17. Security & Compliance

Queue management systems in healthcare and government handle sensitive personal data. HIPAA, GDPR, and SOC2 compliance requirements must be addressed from the ground up. Even in retail deployments, customer privacy and data protection are essential.

Security Architecture

flowchart TB subgraph SecurityLayers WAF["Web Application Firewall"] RateLimit["Rate Limiter"] AuthN["Authentication\n(JWT + MFA)"] AuthZ["Authorization\n(RBAC + ABAC)"] Encrypt["Encryption\n(TLS 1.3 + AES-256)"] Audit["Audit Logging"] end WAF --> RateLimit RateLimit --> AuthN AuthN --> AuthZ AuthZ --> Encrypt Encrypt --> Audit

Role-Based Access Control

RolePermissions
CustomerJoin queue, view own position, leave queue
StaffServe customers, skip, complete, view queue
ManagerStaff permissions + priority override, reports, dashboard
AdminManager permissions + location config, staff management
Super AdminAll permissions + system config, security settings

Data Protection Implementation

C#
public class DataProtectionService : IDataProtectionService
{
    private readonly IKeyVaultClient _keyVault;
    private readonly IEncryptionProvider _encryption;

    public string EncryptPii(string plainText, Guid customerId)
    {
        if (string.IsNullOrEmpty(plainText))
            return plainText;

        var key = _keyVault
            .GetKeyAsync($"customer-{customerId}")
            .GetAwaiter().GetResult();

        return _encryption.EncryptAes256(plainText, key);
    }

    public string DecryptPii(string cipherText, Guid customerId)
    {
        if (string.IsNullOrEmpty(cipherText))
            return cipherText;

        var key = _keyVault
            .GetKeyAsync($"customer-{customerId}")
            .GetAwaiter().GetResult();

        return _encryption.DecryptAes256(cipherText, key);
    }

    public async Task<AuditLogEntry> LogAccessAsync(
        Guid userId, string action, string resource,
        string? details = null)
    {
        var entry = new AuditLogEntry
        {
            Id = Guid.NewGuid(),
            UserId = userId,
            Action = action,
            Resource = resource,
            Details = details,
            Timestamp = DateTimeOffset.UtcNow,
            IpAddress = GetClientIp(),
            UserAgent = GetUserAgent()
        };

        await _db.AuditLogs.AddAsync(entry);
        await _db.SaveChangesAsync();

        return entry;
    }
}

public class AuditLogEntry
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public string Action { get; set; }
    public string Resource { get; set; }
    public string? Details { get; set; }
    public DateTimeOffset Timestamp { get; set; }
    public string? IpAddress { get; set; }
    public string? UserAgent { get; set; }
}

Security Checklist

  • All API endpoints require authentication except public queue join
  • Customer data is encrypted at rest using AES-256
  • All data in transit uses TLS 1.3
  • JWT tokens expire after 1 hour; refresh tokens after 7 days
  • Rate limiting: 100 requests/minute per user, 1000/minute per IP
  • Audit logs retained for 7 years for compliance
  • PII fields are masked in logs (phone numbers, emails)
  • GDPR right to deletion: customers can request full data purge
  • HIPAA BAA signed with all cloud infrastructure providers
  • Quarterly penetration testing and annual SOC2 audit

18. Cost Estimation

Understanding the infrastructure cost is essential for building a business case and ensuring the system is economically viable at scale. Below is a detailed cost breakdown for a deployment serving 500 locations with 250,000 daily customers.

Monthly Infrastructure Cost

ServiceSpecificationMonthly Cost
Azure SQL DatabaseBusiness Critical, 8 vCores, 500 GB$2,800
Azure Redis CachePremium P2, 6 GB$560
Azure App Service4 × Premium v3 P2 instances$1,400
Azure Service BusPremium, 1 MU$800
Azure Blob StorageHot, 500 GB$10
Azure CDN1 TB/month transfer$85
Azure Application Insights50 GB/month ingestion$150
Twilio SMS2M messages/month$15,000
Firebase Cloud Messaging50M messages/month$0 (free tier)
Azure DevOps20 users$100
SSL CertificatesWildcard, auto-renew$0 (free via Let's Encrypt)
DNSAzure DNS$5
Total Monthly~$20,910

Cost Per Customer

MetricValue
Monthly infrastructure cost$20,910
Monthly customers served7,500,000
Cost per customer$0.0028
SMS cost per notification$0.0075
Push notification cost$0.0005
Avg notifications per customer3.5
Notification cost per customer$0.015
Total cost per customer~$0.018
Cost Efficiency: At $0.018 per customer, the system is extremely cost-effective. A location serving 500 customers per day pays only about $270/month in infrastructure costs. This compares favorably to physical queue management hardware (digital signage, ticket dispensers) which can cost $5,000-$15,000 per location in capital expenditure alone.

Cost Optimization Strategies

  • Reserved instances: Commit to 1-year reserved instances for 30-40% savings on compute
  • Batch notifications: Group position updates to reduce SMS costs
  • Smart polling: Use WebSocket for active users, short polling for idle
  • Tiered storage: Move analytics data older than 30 days to cool storage
  • Connection pooling: Use PgBouncer to reduce database connection overhead

19. Testing Strategy

A robust testing strategy is essential for a system where queue state correctness is mission-critical. We employ unit tests, integration tests, contract tests, load tests, and chaos engineering.

Test Pyramid

flowchart TB E2E["E2E Tests\n(Selenium / Playwright)\n~50 tests"] Contract["Contract Tests\n(Pact)\n~30 tests"] Integration["Integration Tests\n(Testcontainers)\n~200 tests"] Unit["Unit Tests\n(xUnit + Moq)\n~800 tests"] Unit --> Integration Integration --> Contract Contract --> E2E

Unit Tests for Queue Logic

C#
public class QueueTicketTests
{
    [Fact]
    public void Call_WhenWaiting_TransitionsToCalled()
    {
        var ticket = CreateWaitingTicket();

        ticket.Call();

        Assert.Equal(TicketStatus.Called, ticket.Status);
        Assert.NotNull(ticket.CalledAt);
        Assert.Null(ticket.Position);
    }

    [Theory]
    [InlineData(TicketStatus.Called)]
    [InlineData(TicketStatus.Serving)]
    [InlineData(TicketStatus.Completed)]
    [InlineData(TicketStatus.NoShow)]
    [InlineData(TicketStatus.Left)]
    public void Call_WhenNotWaiting_ThrowsInvalidTransition(
        TicketStatus currentStatus)
    {
        var ticket = CreateTicketWithStatus(currentStatus);

        Assert.Throws<InvalidTransitionException>(
            () => ticket.Call());
    }

    [Fact]
    public void Complete_WhenServing_TransitionsToCompleted()
    {
        var ticket = CreateServingTicket();

        ticket.Complete();

        Assert.Equal(TicketStatus.Completed, ticket.Status);
        Assert.NotNull(ticket.CompletedAt);
        Assert.Null(ticket.Position);
    }

    [Fact]
    public void MarkNoShow_WhenCalled_IncrementsNoShowFlag()
    {
        var ticket = CreateCalledTicket();

        ticket.MarkNoShow();

        Assert.True(ticket.NoShow);
        Assert.Equal(TicketStatus.NoShow, ticket.Status);
    }
}

public class PriorityQueueCalculatorTests
{
    [Fact]
    public void CalculateEffectivePriority_RegularTicket_AgesOverTime()
    {
        var calculator = new PriorityQueueCalculator(
            agingIncrement: 1,
            agingInterval: TimeSpan.FromMinutes(5));

        var ticket = new QueueTicket
        {
            Priority = 0,
            JoinedAt = DateTimeOffset.UtcNow
                .AddMinutes(-60)
        };

        var effective = calculator
            .CalculateEffectivePriority(ticket);

        Assert.Equal(12, effective);
    }

    [Fact]
    public void CalculateEffectivePriority_VIPTicket_StartsHigher()
    {
        var calculator = new PriorityQueueCalculator();

        var ticket = new QueueTicket
        {
            Priority = 50,
            JoinedAt = DateTimeOffset.UtcNow
        };

        var effective = calculator
            .CalculateEffectivePriority(ticket);

        Assert.Equal(50, effective);
    }

    [Fact]
    public void CalculateEffectivePriority_CapsAtMax()
    {
        var calculator = new PriorityQueueCalculator(
            agingIncrement: 5);

        var ticket = new QueueTicket
        {
            Priority = 0,
            JoinedAt = DateTimeOffset.UtcNow
                .AddHours(-8)
        };

        var effective = calculator
            .CalculateEffectivePriority(ticket);

        Assert.Equal(200, effective);
    }
}

Integration Tests

C#
public class QueueServiceIntegrationTests : IAsyncLifetime
{
    private readonly TestcontainersContainer _postgres;
    private readonly TestcontainersContainer _redis;
    private AppDbContext _db = null!;
    private QueueService _service = null!;

    public async Task InitializeAsync()
    {
        _postgres = new TestcontainersBuilder()
            .WithDatabase(new DatabaseConfiguration
            {
                Database = "queuedb",
                Username = "test",
                Password = "test"
            })
            .Build();

        _redis = new TestcontainersBuilder()
            .WithImage("redis:7-alpine")
            .WithPortBinding(6379, true)
            .Build();

        await Task.WhenAll(
            _postgres.StartAsync(),
            _redis.StartAsync());

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseNpgsql(_postgres.GetConnectionString())
            .Options;

        _db = new AppDbContext(options);
        await _db.Database.MigrateAsync();

        _service = new QueueService(
            _db,
            new RedisCache(_redis.GetConnectionString()),
            new MockWaitTimeEstimator());
    }

    [Fact]
    public async Task JoinQueue_AssignsSequentialPositions()
    {
        var queue = await CreateTestQueue();

        var t1 = await _service.JoinQueueAsync(
            queue.Id, Guid.NewGuid(), "online",
            Guid.NewGuid(), CancellationToken.None);
        var t2 = await _service.JoinQueueAsync(
            queue.Id, Guid.NewGuid(), "online",
            Guid.NewGuid(), CancellationToken.None);
        var t3 = await _service.JoinQueueAsync(
            queue.Id, Guid.NewGuid(), "walk_in",
            Guid.NewGuid(), CancellationToken.None);

        Assert.Equal(1, t1.Position);
        Assert.Equal(2, t2.Position);
        Assert.Equal(3, t3.Position);
    }

    [Fact]
    public async Task ServeNext_ReturnsHighestPriorityFirst()
    {
        var queue = await CreateTestQueue();

        await _service.JoinQueueAsync(
            queue.Id, CreateCustomer("Regular"),
            "online", Guid.NewGuid(), CancellationToken.None);

        await _service.JoinQueueAsync(
            queue.Id, CreateCustomer("VIP"),
            "online", Guid.NewGuid(), CancellationToken.None);

        var vipTicket = await _db.Tickets
            .FirstAsync(t =>
                t.CustomerId != Guid.Empty);
        vipTicket.Priority = 50;
        await _db.SaveChangesAsync();

        var served = await _service.ServeNextAsync(
            queue.Id, CreateStaff(), CancellationToken.None);

        Assert.Equal(50, served!.Priority);
    }

    public async Task DisposeAsync()
    {
        await Task.WhenAll(
            _postgres.DisposeAsync().AsTask(),
            _redis.DisposeAsync().AsTask());
    }
}

Load Testing Scenarios

ScenarioVirtual UsersDurationTarget
Normal Load50030 minP99 < 200ms, 0 errors
Peak Load2,00015 minP99 < 500ms, < 0.1% errors
Spike Test0→5,000→010 minAuto-scales within 2 min
Soak Test1,0008 hoursNo memory leaks, stable P99
Chaos Test50060 minSurvives node failures

20. Interview Q&A

Q1: How do you handle two customers being called to the same window simultaneously?

We use serializable transactions for all state-changing operations on the queue. The ServeNextAsync method acquires a row-level lock on the queue's head ticket using SELECT ... FOR UPDATE. This ensures that even under extreme concurrency, only one ticket transitions to "called" state. Additionally, we maintain a per-window state machine that prevents a window from calling a new customer while one is already in the "called" state (has not yet arrived or been marked no-show).

Q2: What happens if the Redis cache goes down?

The system continues to function correctly because Redis is used only as a read-through cache for performance optimization. All queue state is durably stored in PostgreSQL. When Redis is unavailable, requests fall back to the database directly. The cache is warmed back up incrementally as requests come in. We implement a cache-aside pattern where the application manages cache invalidation explicitly on writes.

Q3: How do you ensure fair ordering when walk-in and online customers are in the same queue?

Walk-in customers join the queue at the current position, just like online customers, maintaining strict FIFO ordering within the same priority level. The source field on the ticket records how the customer joined (for analytics) but does not affect ordering. If the business requires walk-in priority, this is configurable through the priority rules engine by assigning walk-in tickets a higher base priority.

Q4: How would you design the notification system to handle millions of notifications without delays?

We use a dedicated notification service that consumes events from Kafka asynchronously. Each notification type has its own consumer group with independent scaling. We batch notifications by channel (SMS provider, push provider) and use connection pooling to maintain persistent connections. For SMS, we use provider-level batching APIs. Critical notifications (called, served) bypass the batch pipeline and are sent immediately via a dedicated high-priority channel. Dead letter queues capture failed notifications for retry and investigation.

Q5: How do you handle a customer who was called but did not show up to the window?

When a customer is called, a timer starts (configurable, default 5 minutes). If the timer expires without the staff member marking the customer as "serving", the system automatically transitions the ticket to "no_show" status and sends a notification to the customer. The customer is then either moved to the back of the queue (configurable) or removed entirely. The staff member can also manually trigger the no-show at any time.

Q6: How does the wait time estimation handle sudden staff absences?

The estimation service continuously monitors staff availability through the Staff Service. When staff count drops (someone goes on break, leaves early), the estimation recalculates using the staffAvailability factor. The ApplyAdjustments method divides the base estimate by the number of currently active staff. So if 3 of 4 staff members are serving and 1 goes on break, the estimated wait time for new customers increases by approximately 33%. The estimation also factors in upcoming scheduled shifts using the staff schedule service.

Q7: Can the system handle a location losing network connectivity entirely?

Kiosk terminals operate in offline mode using a local SQLite database that caches the last known queue state. Customers can still join via the kiosk, and the kiosk assigns local ticket numbers. When connectivity is restored, the KioskSyncService synchronizes local tickets with the central server. The conflict resolution strategy preserves locally created tickets (giving them new server-side IDs) while adopting the server's current position data. Staff dashboards fall back to the locally cached state and clearly indicate they are in "offline mode".

Q8: How do you prevent abuse of the priority system?

Priority is controlled through multiple layers. Customers cannot self-assign priority; it is set by the system based on verified attributes (appointment status, customer tier, medical triage score). Priority overrides by staff require manager-level authorization and are logged in the audit trail. The aging algorithm prevents permanent starvation, and the MaxPriorityCap ensures no ticket can have unbounded priority. Anomalous priority patterns (e.g., a staff member giving everyone VIP priority) trigger alerts for the operations team.

Q9: What is the database schema migration strategy?

We use Entity Framework Core migrations with a two-phase deployment strategy. Schema changes that are backward-compatible (adding a nullable column, adding an index) are deployed first. Once the migration is live and the application code has been updated, a second migration cleans up any temporary columns or deprecated fields. For breaking changes, we use a blue-green deployment with database views or stored procedures that abstract the old and new schemas during the transition period.

Q10: How do you test the system handles peak loads without a staging environment that mirrors production?

We use a combination of strategies. First, local load tests with testcontainers give us confidence in individual service behavior under load. Second, we deploy a canary instance at 5% traffic on production infrastructure and monitor its metrics before promoting to full capacity. Third, we use chaos engineering tools (like Chaos Mesh) in our staging environment to simulate infrastructure failures. Finally, we maintain performance baselines — every release must not regress P99 latency by more than 10% compared to the previous release baseline.

Q11: How would you extend this system to support video-based virtual queues for remote service delivery?

The current architecture supports this extension by treating a video session as a "virtual window." We would add a VideoService that manages WebRTC sessions between staff and customers. The queue service would call the video service when a customer is "served", establishing a video link instead of directing them to a physical window. The WindowManager would be extended with virtual window objects that track video session state. The notification service would send a video link instead of a window number.

Q12: What are the most important metrics you would monitor in production?

The critical metrics fall into four categories. Business metrics: average wait time, abandonment rate, no-show rate, throughput per hour. System metrics: P99 API latency, error rate, queue depth, cache hit ratio. Infrastructure metrics: CPU utilization, memory usage, database connection pool saturation, Kafka consumer lag. Operational metrics: notification delivery rate, staff utilization, estimation accuracy. We set up PagerDuty alerts for P99 latency exceeding 500ms, error rate exceeding 0.1%, or notification delivery dropping below 90%.

Q13: How do you handle the case where two service windows serve different speeds?

The wait time estimation is per-queue and uses actual historical service times rather than assumed averages. If a window is staffed by a faster worker, that window will naturally call customers more frequently, which is reflected in the real-time throughput metric. The estimation service factors in the current throughput rate (customers actually being served per minute) rather than a theoretical service time. We also allow per-staff service time tracking for managers to identify training opportunities.

Q14: What is the disaster recovery plan?

We deploy PostgreSQL with synchronous replication to a secondary region and use Azure Availability Zones for Redis. In case of a primary region failure, automatic failover occurs within 30 seconds. Kafka is deployed across three availability zones with a replication factor of 3. We maintain point-in-time recovery for the database with 30-day retention. The RPO is under 1 second (synchronous replication), and the RTO is under 60 seconds (automated failover). Quarterly DR drills verify the recovery process works end-to-end.

Q15: How do you scale the system from one location to thousands?

The architecture is designed for horizontal scaling from the start. Each location operates independently with its own database partition (sharded by location ID). Adding a new location requires only inserting a configuration record — no code changes. The API Gateway routes requests to the appropriate location's service instances using location-based routing. Analytics are aggregated from location-level databases to the central analytics store using a change data capture pipeline. Redis is sharded by location ID to distribute cache load.

© 2026 Ayodhyya - The System Design Codex. All rights reserved.

This is part of "The Complete System Design Interview Handbook" series.