Design a Queue Management System: The Complete Guide
Building virtual queues, real-time wait estimation, multi-service routing, and staff assignment at enterprise scale
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.
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
| ID | Requirement | Description |
|---|---|---|
| FR-01 | Join Queue | Customers 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-02 | Real-Time Position | Customers can view their current position in the queue and estimated wait time at any time. |
| FR-03 | Serve Next | Staff can call the next customer from the queue when a service window becomes available. |
| FR-04 | Priority Levels | Support multiple priority tiers (e.g., VIP, regular, walk-in) with configurable promotion rules. |
| FR-05 | Multi-Service | A single location can offer multiple services, each with its own queue or shared queue. |
| FR-06 | Notifications | Send SMS, email, and push notifications for position updates, wait time changes, and turn-to-serve alerts. |
| FR-07 | Walk-In Merge | Walk-in customers can be merged into the online queue with appropriate positioning rules. |
| FR-08 | Staff Dashboard | Staff can view current queue state, customer details, service history, and real-time metrics. |
| FR-09 | Multi-Location | Support multiple physical locations with independent queues and centralized reporting. |
| FR-10 | Appointment Integration | Customers with appointments can be slotted into the queue at their scheduled time. |
| FR-11 | Skip / No-Show | Handle customers who leave the queue or do not show up when called. |
| FR-12 | Analytics | Provide dashboards for average wait times, throughput, peak hours, and staff utilization. |
Non-Functional Requirements
| Attribute | Target | Rationale |
|---|---|---|
| Availability | 99.99% | System must be available during all business hours across time zones |
| Latency (P99) | < 200ms | Queue join and position queries must feel instant |
| Throughput | 10,000 joins/sec | Peak load for large government service centers |
| Consistency | Strong for position | Queue position must never be duplicated or lost |
| Durability | Zero data loss | Queue state must survive node failures |
| Scalability | Horizontal | Must scale across locations without re-architecture |
| Security | HIPAA / GDPR | Healthcare deployments require strict data protection |
| Offline Support | Degrade gracefully | Kiosk 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
| Metric | Calculation | Result |
|---|---|---|
| Locations | Given | 500 |
| Avg customers/location/day | Given | 500 |
| Total customers/day | 500 × 500 | 250,000 |
| Operations per customer | Join + updates + serve ≈ 15 | 15 |
| Total ops/day | 250,000 × 15 | 3,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 Type | Size per Record | Daily Volume | Daily Storage |
|---|---|---|---|
| Queue tickets | 1 KB | 250,000 | 250 MB |
| Position snapshots (every 30s) | 128 B | 250,000 × 60 | ~1.8 GB |
| Events / audit log | 256 B | 3,750,000 | ~900 MB |
| Notification records | 512 B | 500,000 | ~250 MB |
| Total daily | ~3.2 GB |
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
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
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.
Service Responsibilities
| Service | Responsibility | Technology |
|---|---|---|
| API Gateway | Request routing, rate limiting, SSL termination, authentication | Kong / YARP |
| Auth Service | JWT token issuance, role-based access control, OAuth integration | .NET 8 Identity |
| Queue Service | Core queue operations: join, leave, serve, skip, reorder | .NET 8 + EF Core |
| Notification Service | Multi-channel delivery: SMS, push, email, in-app | .NET 8 + Hangfire |
| Estimation Service | Real-time wait time calculation using historical and live data | .NET 8 + ML.NET |
| Staff Service | Staff scheduling, assignment, load balancing, window management | .NET 8 + EF Core |
| Analytics Service | Metrics 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 queuequeue.ticket.position_changed— A customer's position in the queue has changedqueue.ticket.called— A customer has been called to a service windowqueue.ticket.served— Service has begun for a customerqueue.ticket.completed— Service has finished for a customerqueue.ticket.no_show— A customer did not respond when calledqueue.ticket.left— A customer voluntarily left the queuequeue.staff.available— A staff member has become availablequeue.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
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/queues/{queueId}/join | Join a queue (returns ticket) |
| DELETE | /api/v1/queues/{queueId}/tickets/{ticketId} | Leave the queue |
| GET | /api/v1/queues/{queueId}/tickets/{ticketId}/status | Get current position and wait estimate |
| POST | /api/v1/queues/{queueId}/serve-next | Staff: call the next customer |
| POST | /api/v1/queues/{queueId}/tickets/{ticketId}/serve | Staff: begin serving a customer |
| POST | /api/v1/queues/{queueId}/tickets/{ticketId}/complete | Staff: mark service as complete |
| POST | /api/v1/queues/{queueId}/tickets/{ticketId}/skip | Staff: skip a no-show customer |
| GET | /api/v1/queues/{queueId}/state | Get current queue state (dashboard) |
| POST | /api/v1/queues/{queueId}/tickets/{ticketId}/priority | Staff: override priority for a ticket |
Staff & Location Operations
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/staff/check-in | Staff: check in for shift at a location |
| POST | /api/v1/staff/{staffId}/assign-window | Assign staff to a service window |
| GET | /api/v1/locations/{locationId}/dashboard | Location-level dashboard data |
| GET | /api/v1/locations/{locationId}/analytics | Historical analytics for a location |
| POST | /api/v1/walk-in/check-in | Register 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
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);
}
}
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
| Level | Value | Description | Example |
|---|---|---|---|
| Critical | 100 | Immediate service required | Emergency medical patient |
| High | 75 | Expedited service | Elderly, disabled, pregnant |
| VIP | 50 | Premium customer tier | Bank premium account holder |
| Appointment | 25 | Pre-scheduled slot | Online appointment booked |
| Regular | 0 | Standard service | Walk-in or online join |
| Low | -25 | Deprioritized (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
}
}
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
| Model | Description | Pros | Cons |
|---|---|---|---|
| Separate Queues | Each service has its own dedicated queue | Simple, predictable wait times per service | Can lead to unbalanced utilization |
| Shared Queue | All services share one queue, staff handle any type | Better utilization, simpler customer experience | Complex routing, variable service times |
| Hybrid | Dedicated queues with overflow to shared pool | Balance of efficiency and simplicity | Most 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
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
| Metric | Target | Description |
|---|---|---|
| Mean Absolute Error | < 5 minutes | Average 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) | < 50ms | Time to compute an estimate |
| Sample Size for Confidence | > 30/day | Minimum 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
| Event | Channel | Message | Timing |
|---|---|---|---|
| Joined Queue | SMS + Push | "You are #42 in queue. Estimated wait: 38 minutes." | Immediate |
| Position Update | Push | "Your position improved to #30. About 28 minutes remaining." | Every 5 positions |
| 5 Min Warning | SMS + Push | "Please prepare! You will be called in approximately 5 minutes." | When ~5 min remaining |
| Called | SMS + Push + Ring | "Please proceed to Window 7. You have 5 minutes." | Immediate |
| No Response | SMS | "We missed you at Window 7. Please check in at the kiosk." | After 3 min |
| Skipped | SMS | "You have been moved to the end of the queue." | Immediate |
| Service Complete | Push + 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
| Metric | SMS | Push | |
|---|---|---|---|
| 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
| Strategy | Description | Fairness | Complexity |
|---|---|---|---|
| FIFO Merge | Walk-ins get next position after latest online joiner | High | Low |
| Time-Slot Reservation | Walk-ins scan QR, get a time slot based on current queue depth | High | Medium |
| Parallel Queues | Separate walk-in and online queues, serve alternately | Medium | Low |
| Weighted Mix | Configurable ratio: e.g., serve 2 walk-ins for every 3 online | Configurable | High |
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}."
};
}
}
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
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
| Metric | Description | Calculation |
|---|---|---|
| Average Wait Time | Mean time from join to serve | Avg(served_at - joined_at) for completed tickets |
| Median Wait Time | 50th percentile wait | Percentile(served_at - joined_at, 0.5) |
| 90th Percentile Wait | Wait time for 90% of customers | Percentile(served_at - joined_at, 0.9) |
| Throughput | Customers served per hour | Count(completed tickets) in last 60 min |
| Average Service Time | Time spent being served | Avg(completed_at - served_at) |
| No-Show Rate | Percentage of no-shows | Count(no_show) / Count(called) × 100 |
| Abandonment Rate | Left queue before being called | Count(left) / Count(joined) × 100 |
| Staff Utilization | Percentage of time staff is serving | Total service time / shift duration × 100 |
| Peak Hour | Busiest hour of the day | Hour with max joined count |
| Current Queue Depth | Customers currently waiting | Count(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
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
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
| Field | Description | Example |
|---|---|---|
| Slot Duration | Length of each appointment slot | 15 minutes |
| Buffer Time | Gap between slots for prep | 5 minutes |
| Grace Period | Minutes past slot before marked late | 15 minutes |
| Max Slots Per Hour | Capacity constraint | 3 per window |
| Overbooking Rate | Percentage of extra slots to fill no-show gaps | 10% |
| Advance Booking Window | How far ahead customers can book | 30 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
Role-Based Access Control
| Role | Permissions |
|---|---|
| Customer | Join queue, view own position, leave queue |
| Staff | Serve customers, skip, complete, view queue |
| Manager | Staff permissions + priority override, reports, dashboard |
| Admin | Manager permissions + location config, staff management |
| Super Admin | All 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
| Service | Specification | Monthly Cost |
|---|---|---|
| Azure SQL Database | Business Critical, 8 vCores, 500 GB | $2,800 |
| Azure Redis Cache | Premium P2, 6 GB | $560 |
| Azure App Service | 4 × Premium v3 P2 instances | $1,400 |
| Azure Service Bus | Premium, 1 MU | $800 |
| Azure Blob Storage | Hot, 500 GB | $10 |
| Azure CDN | 1 TB/month transfer | $85 |
| Azure Application Insights | 50 GB/month ingestion | $150 |
| Twilio SMS | 2M messages/month | $15,000 |
| Firebase Cloud Messaging | 50M messages/month | $0 (free tier) |
| Azure DevOps | 20 users | $100 |
| SSL Certificates | Wildcard, auto-renew | $0 (free via Let's Encrypt) |
| DNS | Azure DNS | $5 |
| Total Monthly | ~$20,910 |
Cost Per Customer
| Metric | Value |
|---|---|
| Monthly infrastructure cost | $20,910 |
| Monthly customers served | 7,500,000 |
| Cost per customer | $0.0028 |
| SMS cost per notification | $0.0075 |
| Push notification cost | $0.0005 |
| Avg notifications per customer | 3.5 |
| Notification cost per customer | $0.015 |
| Total cost per customer | ~$0.018 |
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
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
| Scenario | Virtual Users | Duration | Target |
|---|---|---|---|
| Normal Load | 500 | 30 min | P99 < 200ms, 0 errors |
| Peak Load | 2,000 | 15 min | P99 < 500ms, < 0.1% errors |
| Spike Test | 0→5,000→0 | 10 min | Auto-scales within 2 min |
| Soak Test | 1,000 | 8 hours | No memory leaks, stable P99 |
| Chaos Test | 500 | 60 min | Survives 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.