·QueueHub Team·19 min read

Building a Queue Dashboard: Real-Time Monitoring UX Patterns

queue dashboardBullMQ monitoringreal-time monitoringqueue observabilitydashboard UX

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:

  1. How to structure real-time metric cards for at-a-glance health
  2. Time-series chart patterns for throughput and processing time
  3. How to compute and display the slowest jobs and failing job types
  4. Polling vs. WebSocket strategies for live queue data
  5. Multi-backend architecture supporting BullMQ and BeeQueue
  6. Empty states, loading skeletons, and error handling patterns
  7. 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 isFetching for 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:

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

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

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

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

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

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