Building a Queue Dashboard: Real-Time Monitoring UX Patterns
Introduction
Every background job system starts simple — a queue, a worker, a Redis instance. You run it locally, it works, you deploy to production, and then the questions start: How many jobs are failing right now? Why is this queue backed up? What's my average processing time? Is Redis running out of memory?
Without a dashboard, you're debugging blind.
Queue observability isn't a luxury — it's an operational necessity. Silent failures in background jobs can corrupt data, degrade customer experience, and mask infrastructure problems until they become emergencies. A well-designed queue dashboard turns an opaque Redis key space into a live picture of system health.
In this post, we'll walk through the UX patterns and implementation decisions behind Queue Hub — an open-source queue management dashboard for BullMQ and BeeQueue. You'll learn:
- How to structure real-time metric cards for at-a-glance health
- Time-series chart patterns for throughput and processing time
- How to compute and display the slowest jobs and failing job types
- Polling vs. WebSocket strategies for live queue data
- Multi-backend architecture supporting BullMQ and BeeQueue
- Empty states, loading skeletons, and error handling patterns
- Redis infrastructure context in queue dashboards
The patterns here are technology-agnostic, but the code examples come straight from a production React + Hono + ioredis stack. Whether you're building a custom dashboard or evaluating existing tools, these patterns will help you design queue observability that engineers actually want to use.
The Overview Dashboard — Designing for At-a-Glance Health
The primary goal of any queue dashboard is to answer one question in under three seconds: Is everything OK? If the answer is no, the secondary goal is to guide the engineer toward the problem as fast as possible.
The Metric Cards Grid
The most important numbers need to be visible immediately, without scrolling or clicking. We use a responsive grid of metric cards that surfaces exactly six high-level signals:
| Metric | What It Tells You |
|---|---|
| Completed | Total jobs that finished successfully in the selected time range |
| Failed | Total job failures — surfaced prominently because failures are the most critical signal |
| Failure Rate | Failed / Total × 100 — normalizes for traffic volume |
| Avg Throughput/hr | How many jobs the system processes per hour — enables capacity estimation |
| Avg Processing Time | Mean job duration — spot regressions fast |
| Avg Delay | Mean time a job waited before processing — indicates queue backlogs |
The server computes these metrics from raw job data, keeping the client lean:
function buildMetrics(
jobs: JobSummary[],
timeRangeHours: number,
) {
const completed = jobs.filter((j) => j.status === "completed");
const failed = jobs.filter((j) => j.status === "failed");
const withTime = jobs.filter((j) => j.processedOn && j.finishedOn);
const total = jobs.length;
const avgProcessingTimeMs =
withTime.length > 0
? withTime.reduce((s, j) => s + (j.finishedOn! - j.processedOn!), 0) /
withTime.length
: 0;
return {
totalCompleted: completed.length,
totalFailed: failed.length,
avgThroughputPerHour: total / timeRangeHours,
failureRate: total > 0 ? (failed.length / total) * 100 : 0,
avgProcessingTimeMs,
avgDelayMs,
};
}
The MetricCard component itself is deliberately minimal — a number, a label, an icon, and judicious color:
function MetricCard({ title, value, sub, icon: Icon, colorClass }: Props) {
return (
<Card className="relative overflow-hidden">
<CardContent className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{title}
</p>
<p className={`mt-1 text-2xl font-bold tabular-nums ${colorClass}`}>
{value}
</p>
{sub && (
<p className="mt-0.5 text-xs text-muted-foreground">{sub}</p>
)}
</div>
<div
className={`shrink-0 rounded-lg p-2.5 bg-current/10 ${colorClass}`}
>
<Icon className={`h-5 w-5 ${colorClass}`} />
</div>
</div>
</CardContent>
</Card>
);
}
Design decisions worth noting:
- Server-side computation — Redis is fast, but scanning thousands of jobs per request still costs. Moving aggregation to the server keeps the React components stateless and the bundle small. The tradeoff is scalability with very large datasets — for queues with millions of jobs, you'll eventually need paginated queries or pre-aggregated counters.
- Color-coded icons — Green for success, red for failures, blue for throughput. Red immediately draws the eye to problems. We use Lucide icons that are distinct shapes (CircleCheck, CircleX, BarChart3) so the differentiation works for colorblind users.
- Tabular-nums font variant — Prevents layout shift as numbers change length. A small CSS class that makes the dashboard feel more like a real monitoring tool.
Metric Cards + Queue Filter
A queue filter lets you scope metrics to a specific queue or see aggregate data across all queues. This is essential in multi-queue deployments where teams own different job streams:
<Select
value={queueName || "all"}
onValueChange={(v) => setQueueName(v === "all" ? null : v)}
>
<SelectItem value="all">All Queues</SelectItem>
{queues.map((q) => (
<SelectItem key={q.name} value={q.name}>
{q.name}
</SelectItem>
))}
</Select>
When a specific queue is selected, every metric card, chart, and table in the overview recalculates for that scope. The query key for the TanStack Query hook includes both queueName and timeRange, so caching is granular and automatic.
Time-Series Charts — Throughput and Processing Time
Numbers give you the current state. Charts give you the story — how we got here, and where we're heading.
Throughput Chart (Area Chart)
The throughput chart shows completed vs. failed jobs over time, letting engineers correlate failures with deployment windows, traffic spikes, or infrastructure changes.
Implementation details from Queue Hub:
- Recharts
<AreaChart>with two series: completed (green) and failed (red) - Hour-bucket aggregation on the server side — each data point represents one hour
- Gradient fills for visual polish
- Custom tooltip that matches the app's dark/light theme
- Adaptive label density — fewer X-axis ticks for longer time ranges
Server-side bucketing:
function buildTimeSeries(jobs: JobSummary[], hours: number) {
// Create hour-buckets for the full range
const buckets = new Map<number, JobSummary[]>();
const now = Date.now();
for (let i = 0; i < hours; i++) {
const h = Math.floor((now - i * 3600_000) / 3600_000) * 3600_000;
buckets.set(h, []);
}
// Bucket each job by its finishedOn timestamp
for (const job of jobs) {
if (job.finishedOn) {
const h = Math.floor(job.finishedOn / 3600_000) * 3600_000;
buckets.get(h)?.push(job);
}
}
// Map buckets to data points
return Array.from(buckets.entries())
.map(([ts, bJobs]) => ({
timestamp: ts,
completed: bJobs.filter((j) => j.status === "completed").length,
failed: bJobs.filter((j) => j.status === "failed").length,
avgProcessingTimeMs: computeAvg(bJobs),
}))
.sort((a, b) => a.timestamp - b.timestamp);
}
Processing Time Chart (Bar Chart)
A bar chart makes processing time regressions visually obvious — the taller the bar, the slower the jobs. The same hour-bucket data powers this chart, just projecting a different dimension:
<BarChart data={filtered}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="timestamp" tickFormatter={formatHour} />
<YAxis tickFormatter={(v) => formatDuration(v)} />
<Tooltip formatter={(v) => [formatDuration(v), "Avg Time"]} />
<Bar
dataKey="avgProcessingTimeMs"
fill="hsl(var(--primary))"
radius={[4, 4, 0, 0]}
/>
</BarChart>
Adaptive Data Density
Long time ranges (3 days, 7 days) can produce 168+ data points. Showing every X-axis label would create unreadable overlap. We adapt the density dynamically:
// Reduce X-axis labels for long time ranges to prevent overlap
const step = timeRange > 48 ? 12 : timeRange > 12 ? 6 : 2;
const filtered = data.filter(
(_, i) => i % step === 0 || i === data.length - 1
);
BullMQ Built-In Metrics Alternative
BullMQ has built-in per-minute metrics via queue.getMetrics():
const metrics = await queue.getMetrics(
"completed",
0,
MetricsTime.ONE_WEEK * 2
);
// Returns: { data: number[], count: number, meta: { ... } }
These are per-minute counters from the worker — you get them essentially for free with minimal RAM cost (~120KB per queue for two weeks). The tradeoff is that they only give you count data, not per-job metadata like individual processing times or error reasons. Queue Hub's approach of querying job summaries provides richer data at higher query cost — a classic tradeoff between system overhead and analytical depth.
Slowest Jobs and Failing Job Types Tables
Metric cards and charts tell you that something is wrong. Tables tell you what is wrong.
Slowest Jobs Table
Engineers need to identify outliers — the one job that takes 30 seconds while everything else finishes in 200ms. The slowest jobs table gives a sorted view of the top 10 worst offenders:
function buildSlowest(jobs: JobSummary[]) {
return jobs
.filter((j) => j.processedOn && j.finishedOn)
.map((j) => ({
id: j.id,
name: j.name,
queueName: j.queueName,
processingTimeMs: j.finishedOn! - j.processedOn!,
timestamp: j.timestamp,
status: j.status,
}))
.sort((a, b) => b.processingTimeMs - a.processingTimeMs)
.slice(0, 10); // Top 10
}
UI details from the real implementation:
- Clickable job names — each row links to the job detail view for full introspection
- Duration coloring — processing times are colored orange/yellow when they exceed reasonable thresholds
- Queue name — shown as secondary info (critical for multi-queue setups where "sendEmail" exists in multiple queues)
- Relative timestamps — "2m ago" helps engineers quickly correlate slow jobs with recent deployments or configuration changes
Failing Job Types Table
Individual failures happen. The question is: Are failures concentrated in a specific job type? Grouping failures by queueName:jobName reveals patterns that a raw list would hide:
function buildFailingTypes(failed: JobSummary[]) {
const grouped = new Map<string, JobSummary[]>();
for (const job of failed) {
const key = `${job.queueName}:${job.name}`;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key)!.push(job);
}
return Array.from(grouped.entries())
.map(([key, jobs]) => {
const sorted = [...jobs].sort(
(a, b) => (b.finishedOn || 0) - (a.finishedOn || 0)
);
return {
name: key.split(":").slice(1).join(":"),
queueName: key.split(":")[0],
failureCount: jobs.length,
lastFailedAt: sorted[0]?.finishedOn,
lastFailedReason: sorted[0]?.failedReason,
};
})
.sort((a, b) => b.failureCount - a.failureCount)
.slice(0, 10);
}
UI details:
- Failure count in red — sorted descending so the worst offender is always first
- Last failure reason — truncated below the job type name (hover to see full error)
- Relative timestamp — "3 min ago" tells you whether this is a current outage or a historical issue
- Empty state — when there are zero failures, the table shows: "No failures — No failed jobs in this time range." with an AlertCircle icon
Polling Strategies for Real-Time Data
One of the most common architectural questions for queue dashboards is: Polling or WebSockets?
Queue Hub uses polling — and this is a deliberate, defended tradeoff.
| Approach | Pros | Cons |
|---|---|---|
Polling (TanStack refetchInterval) |
Simple, works everywhere, no infrastructure, cache invalidation built-in | Not truly real-time; extra Redis load on each poll |
| WebSockets | True real-time, lower bandwidth | Requires persistent connection, reconnection logic, more state management |
| Redis Pub/Sub | Event-driven, minimal latency | Requires persistent WS bridge from Redis events to browser |
Why Polling Works for Queue Dashboards
The key insight is that queue metrics don't need millisecond precision. An operations dashboard viewed on a monitor or in a browser tab is fundamentally different from an automated alerting system:
// Queue list — refreshes every 15s
useQuery({
queryKey: ["queues"],
queryFn: queuesApi.list,
refetchInterval: 15_000, // 15 seconds
});
// Overview metrics — refreshes every 30s
useQuery({
queryKey: ["overview", queueName, timeRange],
queryFn: () => overviewApi.metrics({ queueName, timeRangeHours: timeRange }),
refetchInterval: 30_000,
});
// Queue detail page — 10s
useQuery({
queryKey: ["queues", queueName],
queryFn: () => queuesApi.get(queueName),
refetchInterval: 10_000,
});
Rationale:
- Metrics don't change every second — 30 seconds between refreshes is sufficient for an operations dashboard
- Queue lists need to update faster (15 seconds) because users navigate between queues
- Queue detail pages are the most interactive — a 10-second interval catches status changes promptly
- TanStack Query automatically deduplicates concurrent requests, caches responses, and provides
isFetchingfor loading indicators
The Manual Refresh Button
Auto-refresh is great, but sometimes an engineer needs to see right now what changed after retrying a job:
<Header
title="Overview"
onRefresh={() => refetch()}
isRefreshing={isFetching}
/>
We use TanStack Query's isFetching (not isLoading) for the spinner. This means the refresh button shows activity during background refetches without replacing the existing data — users always see the last successful result while the new data loads.
Cache Invalidation on Mutations
When a user performs an action — pausing a queue, retrying a job — stale data must be invalidated immediately (not on the next 30-second poll):
const pauseMutation = useMutation({
mutationFn: () => queuesApi.pause(queueName),
onSuccess: () => {
toast.success("Queue paused");
queryClient.invalidateQueries({ queryKey: ["queues"] });
queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
});
This pattern ensures that the UI reflects mutations within milliseconds, while non-interactive data refreshes on a comfortable 15–30 second cadence.
Multi-Backend Architecture — BullMQ + BeeQueue
Not every queue system is BullMQ. BeeQueue has a strong legacy footprint, and a well-designed dashboard should support both.
The QueueBackend Interface
The core abstraction is a TypeScript interface that both backends implement:
interface QueueBackend {
connect(): Promise<void>;
disconnect(): Promise<void>;
getQueues(): Promise<Queue[]>;
getQueue(name: string): Promise<Queue | null>;
getJobCounts(queueName: string): Promise<JobCounts>;
getJobs(queueName: string, options?: JobQueryOptions): Promise<Job[]>;
getJob(queueName: string, jobId: string): Promise<Job | null>;
retryJob(queueName: string, jobId: string): Promise<void>;
// ... plus pause, resume, clean, delete, workers, etc.
}
Both BullMQService and BeeQueueService implement this interface. The server resolves the correct backend at runtime:
async function resolveSvc(userId: string, orgId: string | null) {
const settings = orgId
? await getOrgSettings(orgId)
: await getUserSettings(userId);
if (settings.queueBackend === "beequeue") {
return ensureBeeQueueConnectedForUser(
key,
settings.redisUrl,
settings.bullmqPrefix
);
}
return ensureConnectedForUser(key, settings.redisUrl, settings.bullmqPrefix);
}
Key Differences in Practice
| Feature | BullMQ | BeeQueue |
|---|---|---|
| Queue class | new Queue(name, { connection }) |
new BeeQueue(name, { redis, keyPrefix }) |
| Job counts | queue.getJobCounts() |
queue.checkHealth() (returns succeeded not completed) |
| Status mapping | native | succeeded → completed, retrying → active |
| Queue discovery | SCAN {prefix}:*:meta |
SCAN {prefix}:*:jobs |
| Clean/delete/pause | Full native support | Not supported (handled as no-ops) |
The status mapping function for BeeQueue is small but essential:
function mapBqStatus(status: string): JobStatus {
switch (status) {
case "succeeded":
return "completed";
case "failed":
return "failed";
case "retrying":
return "active";
case "created":
return "waiting";
default:
return "waiting";
}
}
This interface-driven approach means the entire frontend — metric cards, charts, tables, job detail views — is written once against the abstract QueueBackend type. Adding support for a new queue system (BullMQ v3, QStash, or custom Redis queue) means writing one new class, not refactoring the UI.
Queue Discovery via Redis SCAN
Both backends discover queues by scanning Redis keys. The critical decision here is to use SCAN not KEYS:
async function discoverQueues(): Promise<string[]> {
this.assertConnected();
const pattern = `${this.prefix}:*:meta`;
const keys: string[] = [];
let cursor = "0";
do {
const [next, results] = await this.connection!.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = next;
keys.push(...results);
} while (cursor !== "0");
return [...new Set(keys.map((k) => k.split(":")[1]).filter(Boolean))];
}
Why SCAN not KEYS: KEYS blocks Redis for the duration of the scan — on large key spaces, this can cause milliseconds or even seconds of latency for all other operations. SCAN with cursor-based iteration is non-blocking and production-safe. The tradeoff is that it provides only eventual consistency (may miss keys added during iteration) — which is perfectly acceptable for a dashboard that refreshes every 15 seconds anyway.
Time Range Selection
Metrics mean nothing without context. A dashboard showing only the last minute is jumpy and useless. A dashboard showing only the last 7 days can hide current problems in averaging. The right approach is a set of opinionated time ranges:
const TIME_RANGES = [
{ value: "1", label: "Last 1h" },
{ value: "6", label: "Last 6h" },
{ value: "24", label: "Last 24h" },
{ value: "72", label: "Last 3d" },
{ value: "168", label: "Last 7d" },
];
const { data: metrics } = useQuery({
queryKey: ["overview", queueName, timeRange],
queryFn: () =>
overviewApi.metrics({
queueName: queueName || undefined,
timeRangeHours: timeRange,
}),
refetchInterval: 30_000,
});
Key UX decisions:
- Default to 24h — gives enough context without being overwhelming
- No custom ranges (yet) — KISS for v1. The pre-set buttons cover 99% of debugging scenarios
- Chart labels adapt to the range — longer ranges show fewer labels (see adaptive step pattern above)
- Metric cards recalculate for the selected range — the values are not absolute, they're time-bound. Switching from 1h to 7d automatically recomputes avg throughput and failure rate
Empty States and Loading States
Dashboard UIs that go blank when there's no data create cognitive friction — is the query broken? Did Redis go down? Or are there genuinely zero jobs? We handle exactly three states for every view:
{isLoading ? (
<OverviewSkeleton />
) : queues?.length === 0 ? (
<EmptyState
icon={<Database className="h-12 w-12" />}
title="No queues found"
description="No BullMQ queues were found in Redis. Create a queue in your application to get started."
/>
) : metrics ? (
<div className="space-y-6">
<MetricCardsGrid />
<Charts />
<Tables />
</div>
) : null}
Why Skeletons Over Spinners
Skeletons communicate the layout structure immediately. When a user navigates to the dashboard, they see the familiar grid of rectangles where cards will appear — the brain starts parsing the layout before the data arrives. This reduces perceived latency significantly:
function OverviewSkeleton() {
return (
<div className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-28" />
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<Skeleton className="h-72" />
<Skeleton className="h-72" />
</div>
</div>
);
}
Empty State Patterns
Every table and section in the dashboard has a purpose-built empty state:
- Slowest Jobs table: "No data — No completed jobs in this time range." + Timer icon
- Failing Job Types table: "No failures — No failed jobs in this time range." + AlertCircle icon
- No queues: "No queues found — No BullMQ queues were found in Redis." + Database icon
- No workers: "No workers connected — No active workers are processing jobs." + Plug icon
The common pattern: tell the user what's missing, what the likely cause is, and what to do about it. Never just "No data."
Redis Info Card — Infrastructure Context
A queue dashboard lives or dies by Redis. If Redis runs out of memory or hits max clients, queues will start failing silently. The Redis Info card keeps infrastructure status front and center:
function parseRedisInfo(raw: string): RedisInfo {
const get = (key: string) =>
raw.match(new RegExp(`^${key}:(.+)$`, "m"))?.[1]?.trim();
return {
version: get("redis_version"),
mode: get("redis_mode"),
usedMemory: get("used_memory_human"),
totalMemory:
get("total_system_memory_human") || get("maxmemory_human"),
connectedClients: parseInt(get("connected_clients") || "0", 10),
maxClients: parseInt(get("maxclients") || "0", 10),
};
}
The card shows:
- Connection status — green/red indicator for connected or disconnected
- Host URL — sanitized (no password in display)
- Database index
- Redis version and mode — standalone, cluster, or sentinel
- Memory usage — used / total, as a warning signal for OOM risks
- Connected clients — against max clients, to spot connection exhaustion
This is the silent sentinel of the dashboard. When everything is fine, it's barely noticed. When things go wrong, it's the first place an engineer looks.
Full Architecture Stack
For reference, here's the complete technology stack behind Queue Hub's dashboard:
┌─────────────────────────────────────────────────────┐
│ React 18 + Vite │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ TanStack │ │ Recharts │ │ shadcn/ui + Tailwind │ │
│ │ Query │ │ (charts) │ │ (components) │ │
│ └──────────┘ └──────────┘ └──────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ API Client (fetch + /api/*) │
├─────────────────────────────────────────────────────┤
│ Hono (Bun) Server │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │ Routes │ │ Services │ │ Notification System │ │
│ │ (/api/*) │ │ (metrics)│ │ (email/telegram/ │ │
│ │ │ │ │ │ webhook) │ │
│ └──────────┘ └──────────┘ └──────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────┐ │
│ │ QueueBackend │ │
│ │ Interface │ │
│ └──────┬───────┬───────┘ │
│ │ │ │
│ ┌─────▼──┐ ┌──▼──────┐ │
│ │BullMQ │ │BeeQueue │ │
│ │Service │ │Service │ │
│ └────┬───┘ └────┬────┘ │
│ │ │ │
│ ┌────▼──────────▼────┐ │
│ │ ioredis │ │
│ └────────┬───────────┘ │
├──────────────┼──────────────────────────────────────┤
│ ▼ │
│ Redis/Valkey/ElastiCache │
└─────────────────────────────────────────────────────┘
Conclusion
Building a queue dashboard that engineers actually want to use comes down to a few core patterns:
-
Polling (not WebSockets) is often sufficient — 15 to 30 second intervals backed by TanStack Query's smart caching give a real-time feel without WebSocket infrastructure. Reserve WebSockets for applications where latency matters in milliseconds.
-
Compute metrics server-side — keep React components clean and stateless by pushing aggregation to the Hono API layer. The client receives pre-computed data points and renders them immediately.
-
The four-panel layout covers 90% of scenarios — metric cards for at-a-glance health, through-put chart, processing time chart, and the slowest/failing jobs tables. Add Redis info for infrastructure context.
-
Abstract with a QueueBackend interface — write your frontend once against a contract, then support BullMQ, BeeQueue, or custom queue systems by implementing that contract. Your UI never needs to know which backend it's talking to.
-
Always handle loading, empty, and error states — for dashboard UIs, missing data is confusing. Every section should explicitly tell the user what's happening: loading skeleton, empty state with explanation, or rendered data.
-
Design for the glance — the most important numbers (failures, throughput, processing time) should be visible within three seconds of opening the dashboard. Every additional click or scroll erodes user trust.
If you're building queue observability for your own systems, these patterns are a solid starting point. If you'd rather use a battle-tested implementation, check out Queue Hub — the open-source dashboard that implements every pattern discussed here. The full source is available, and each line of code in this post maps directly to production code.
Try Queue Hub at queuehub.tech — your queues will thank you.
Related Articles
QueueHub vs Taskforce: A Modern Alternative for BullMQ Monitoring
Taskforce (taskforce.sh) was a popular hosted dashboard for BullMQ — but with its uncertain status, many teams are looking for an alternative. We compare Taskforce's feature set against QueueHub across pricing, Redis inspection, multi-backend support, and team collaboration.
Real-Time Queue Dashboards Without Polling: Event-Driven Architecture with BullMQ QueueEvents
Stop polling Redis and start streaming. Learn to build a real-time BullMQ queue dashboard using QueueEvents, WebSockets, and SSE for live job monitoring, P50/P95/P99 latency tracking, multi-tenant event filtering, and Slack/PagerDuty alerting — with zero polling.