BullMQ Scheduler Orchestration: Health Monitoring, Multi-Tenant Patterns & Performance at Scale
Most BullMQ scheduling guides stop at upsertJobScheduler with a cron pattern and call it done. In production, teams managing hundreds or thousands of schedulers face a completely different set of problems — schedulers that silently stop producing jobs, memory pressure from orphaned repeat keys, drift between scheduled and actual execution times, and the operational cost of enumerating 10,000 schedulers to check if they are alive.
This post fills the gap between "I know how to create a scheduler" and "I can trust 5,000 schedulers in production." You will learn how to build a programmatic CRUD service for schedulers, detect unhealthy schedules before they miss fires, manage schedulers across hundreds of tenants, and understand the memory and performance characteristics of large scheduler fleets.
Programmatic Scheduler Management Without a Database
When schedules are managed by operators or automated scripts rather than end users, you can treat BullMQ's Redis-backed scheduler state as the source of truth instead of maintaining a separate database. This lighter-weight approach works well when schedules number under 5,000 per queue, changes happen at minutes-to-hours granularity, and full audit history is captured at the caller level.
CRUD Service for Schedulers
The core building block is a thin service class wrapping BullMQ's scheduler API with validation and error handling:
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({
host: 'localhost',
port: 6379,
maxRetriesPerRequest: null,
});
const queue = new Queue('scheduled-tasks', { connection });
interface SchedulerSpec {
schedulerId: string;
every?: number;
pattern?: string;
tz?: string;
startDate?: number;
endDate?: number;
limit?: number;
data?: Record<string, unknown>;
}
export class SchedulerService {
constructor(private readonly queue: Queue) {}
async create(input: SchedulerSpec): Promise<void> {
const existing = await this.queue.getJobScheduler(input.schedulerId);
if (existing) {
throw new Error(`Scheduler "${input.schedulerId}" already exists`);
}
await this.upsert(input);
}
async get(schedulerId: string) {
const scheduler = await this.queue.getJobScheduler(schedulerId);
if (!scheduler) {
throw new Error(`Scheduler "${schedulerId}" not found`);
}
return scheduler;
}
async list(offset = 0, limit = 100) {
const schedulers = await this.queue.getJobSchedulers(offset, limit - 1, true);
return { schedulers, hasMore: schedulers.length === limit };
}
async update(input: SchedulerSpec): Promise<void> {
const existing = await this.queue.getJobScheduler(input.schedulerId);
if (!existing) {
throw new Error(`Scheduler "${input.schedulerId}" not found`);
}
await this.upsert(input);
}
async remove(schedulerId: string): Promise<boolean> {
return this.queue.removeJobScheduler(schedulerId);
}
private async upsert(input: SchedulerSpec): Promise<void> {
const { schedulerId, data, ...repeatOpts } = input;
await this.queue.upsertJobScheduler(
schedulerId,
repeatOpts,
data ? { name: schedulerId, data } : undefined,
);
}
}
Bulk Operations with Error Isolation
When onboarding a new tenant or deploying a configuration change, you may need to create or update hundreds of schedulers at once. Sequential calls are slow, so batch with controlled concurrency:
async function bulkUpsertSchedulers(
queue: Queue,
schedulers: SchedulerSpec[],
batchSize = 25,
): Promise<{ created: number; failed: { id: string; error: string }[] }> {
const failed: { id: string; error: string }[] = [];
let created = 0;
for (let i = 0; i < schedulers.length; i += batchSize) {
const batch = schedulers.slice(i, i + batchSize);
const results = await Promise.allSettled(
batch.map((s) =>
queue.upsertJobScheduler(
s.schedulerId,
{ pattern: s.pattern, every: s.every, tz: s.tz },
s.data ? { name: s.schedulerId, data: s.data } : undefined,
),
),
);
for (let j = 0; j < results.length; j++) {
if (results[j].status === 'fulfilled') {
created++;
} else {
failed.push({
id: batch[j].schedulerId,
error: results[j].reason?.message ?? String(results[j].reason),
});
}
}
}
return { created, failed };
}
Scheduler Validation Layer
Before touching Redis, validate that the scheduler configuration will actually produce jobs. Catch bad cron expressions, impossible timezones, and mutually exclusive options early:
import { parseExpression } from 'cron-parser';
interface ValidationResult {
valid: boolean;
errors: string[];
nextExecution: number | null;
}
function validateSchedulerConfig(input: {
schedulerId: string;
pattern?: string;
every?: number;
tz?: string;
startDate?: number;
endDate?: number;
}): ValidationResult {
const errors: string[] = [];
if (!input.pattern && !input.every) {
errors.push('Either "pattern" or "every" is required');
}
if (input.pattern && input.every) {
errors.push('"pattern" and "every" are mutually exclusive');
}
if (input.pattern) {
try {
const interval = parseExpression(input.pattern, {
currentDate: new Date(),
tz: input.tz,
});
const next = interval.next().getTime();
if (input.endDate && next > input.endDate) {
errors.push('Next execution is past the specified endDate');
}
return { valid: errors.length === 0, errors, nextExecution: next };
} catch (e) {
errors.push(`Invalid cron pattern: ${(e as Error).message}`);
return { valid: false, errors, nextExecution: null };
}
}
if (input.every && input.every < 100) {
errors.push('"every" must be at least 100ms');
}
const next = input.every ? Date.now() + input.every : null;
return { valid: errors.length === 0, errors, nextExecution: next };
}
Scheduler Health Monitoring — Detecting Missed Fires and Dead Schedulers
BullMQ's scheduler system works by creating a delayed job for each scheduler's next execution. When a worker picks up that job, the Repeat class computes the next timestamp and creates a new delayed job. The critical invariant: a scheduler is healthy if and only if there is a delayed job waiting for its next execution. If that delayed job is missing, the scheduler will never fire again.
Measuring Scheduler Drift with prevMillis
Every job produced by a scheduler carries a prevMillis property — the timestamp of the previous iteration. Comparing prevMillis with the actual processing time reveals scheduler drift:
import { Worker, Job } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis({
host: 'localhost',
port: 6379,
maxRetriesPerRequest: null,
});
interface SchedulerDriftMetric {
schedulerId: string;
expectedNextMillis: number;
actualProcessedMillis: number;
driftMs: number;
}
class SchedulerDriftMonitor {
private drifts: SchedulerDriftMetric[] = [];
recordDrift(job: Job): void {
const prevMillis = job.opts?.prevMillis as number | undefined;
const repeatJobKey = job.opts?.repeatJobKey as string | undefined;
if (prevMillis === undefined || !repeatJobKey) {
return;
}
const now = Date.now();
this.drifts.push({
schedulerId: repeatJobKey,
expectedNextMillis: prevMillis,
actualProcessedMillis: now,
driftMs: now - prevMillis,
});
if (this.drifts.length > 1000) {
this.drifts.shift();
}
}
getAverageDrift(windowMs = 300_000): number {
const cutoff = Date.now() - windowMs;
const recent = this.drifts.filter((d) => d.actualProcessedMillis > cutoff);
if (recent.length === 0) return 0;
return recent.reduce((sum, d) => sum + d.driftMs, 0) / recent.length;
}
getSchedulersWithExcessiveDrift(thresholdMs = 60_000): SchedulerDriftMetric[] {
return this.drifts.filter((d) => d.driftMs > thresholdMs);
}
}
// Usage in a worker
const driftMonitor = new SchedulerDriftMonitor();
const worker = new Worker(
'scheduled-tasks',
async (job) => {
driftMonitor.recordDrift(job);
// ... process the job
},
{ connection },
);
setInterval(() => {
const avgDrift = driftMonitor.getAverageDrift();
const highDrift = driftMonitor.getSchedulersWithExcessiveDrift();
if (highDrift.length > 0) {
console.log(JSON.stringify({
type: 'scheduler_drift',
averageDriftMs: avgDrift,
highDriftCount: highDrift.length,
samples: highDrift.slice(0, 5).map((d) => ({
id: d.schedulerId,
driftMs: d.driftMs,
})),
}));
}
}, 15_000);
Dead Scheduler Detection — Verifying the Delayed Job Exists
Since BullMQ v5.32.0, getJobScheduler returns the scheduler metadata including its next execution timestamp. Use this to build a liveness probe that checks whether the expected delayed job actually exists:
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
const healthConnection = new IORedis({
host: 'localhost',
port: 6379,
maxRetriesPerRequest: null,
});
const healthQueue = new Queue('scheduled-tasks', { connection: healthConnection });
interface SchedulerHealth {
schedulerId: string;
alive: boolean;
nextMillis: number | null;
hasDelayedJob: boolean;
reason?: string;
}
async function checkSchedulerHealth(schedulerId: string): Promise<SchedulerHealth> {
const scheduler = await healthQueue.getJobScheduler(schedulerId);
if (!scheduler) {
return {
schedulerId,
alive: false,
nextMillis: null,
hasDelayedJob: false,
reason: 'Scheduler not found in Redis',
};
}
const delayedJobId = `repeat:${scheduler.key}:${scheduler.next}`;
const job = await healthQueue.getJob(delayedJobId);
const now = Date.now();
const next = scheduler.next ?? 0;
const gracePeriodMs = 300_000;
const isPastDue = next > 0 && next < now - gracePeriodMs;
return {
schedulerId,
alive: job !== null && !isPastDue,
nextMillis: next,
hasDelayedJob: job !== null,
reason: !job
? 'No delayed job found — scheduler will not fire'
: isPastDue
? `Delayed job is past due by ${now - next}ms`
: undefined,
};
}
async function healthCheckAllSchedulers(
batchSize = 50,
): Promise<{ healthy: number; unhealthy: SchedulerHealth[]; errors: string[] }> {
const healthy: SchedulerHealth[] = [];
const unhealthy: SchedulerHealth[] = [];
const errors: string[] = [];
let offset = 0;
let running = true;
while (running) {
const schedulers = await healthQueue.getJobSchedulers(offset, batchSize - 1, true);
if (schedulers.length === 0) {
running = false;
break;
}
const results = await Promise.allSettled(
schedulers.map((s) => checkSchedulerHealth(s.key)),
);
for (const result of results) {
if (result.status === 'fulfilled') {
if (result.value.alive) {
healthy.push(result.value);
} else {
unhealthy.push(result.value);
}
} else {
errors.push(result.reason?.message ?? String(result.reason));
}
}
running = schedulers.length === batchSize;
offset += batchSize;
}
return { healthy: healthy.length, unhealthy, errors };
}
Redis TTL Heartbeat for Lightweight Liveness
BullMQ does not natively expose scheduler-level heartbeats, but you can implement your own with Redis SET + PX (expiry in milliseconds). This gives you a lightweight liveness signal that works even when enumerating all schedulers is too expensive:
import IORedis from 'ioredis';
class SchedulerHeartbeat {
private readonly ttlMs: number;
constructor(
private readonly redis: IORedis,
private readonly queueName: string,
private readonly prefix = 'bull',
opts?: { ttlMs?: number },
) {
this.ttlMs = opts?.ttlMs ?? 60_000;
}
private heartbeatKey(schedulerId: string): string {
return `${this.prefix}:${this.queueName}:scheduler:heartbeat:${schedulerId}`;
}
async beat(schedulerId: string): Promise<void> {
await this.redis.set(
this.heartbeatKey(schedulerId),
Date.now().toString(),
'PX',
this.ttlMs,
);
}
async isAlive(schedulerId: string): Promise<boolean> {
const val = await this.redis.get(this.heartbeatKey(schedulerId));
return val !== null;
}
async listDeadSchedulers(schedulerIds: string[]): Promise<string[]> {
if (schedulerIds.length === 0) return [];
const pipeline = this.redis.pipeline();
for (const id of schedulerIds) {
pipeline.get(this.heartbeatKey(id));
}
const results = await pipeline.exec();
if (!results) return schedulerIds;
return schedulerIds.filter((_, i) => results[i][1] === null);
}
async clear(schedulerId: string): Promise<void> {
await this.redis.del(this.heartbeatKey(schedulerId));
}
}
Integrate the heartbeat into a worker wrapper so every scheduler-produced job automatically refreshes the TTL:
import { Worker, Job } from 'bullmq';
function createMonitoredWorker(
queueName: string,
processor: (job: Job) => Promise<void>,
connection: IORedis,
): { worker: Worker; heartbeat: SchedulerHeartbeat } {
const heartbeat = new SchedulerHeartbeat(connection, queueName);
const worker = new Worker(
queueName,
async (job) => {
const repeatJobKey = job.opts?.repeatJobKey as string | undefined;
if (repeatJobKey) {
await heartbeat.beat(repeatJobKey);
}
await processor(job);
},
{ connection, concurrency: 10 },
);
return { worker, heartbeat };
}
Prometheus Metrics for Scheduler Health
Expose scheduler health as Prometheus gauge metrics so your monitoring platform can alert on silent failures:
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import express from 'express';
const metricsConnection = new IORedis({
host: 'localhost',
port: 6379,
maxRetriesPerRequest: null,
});
const metricsQueue = new Queue('scheduled-tasks', { connection: metricsConnection });
async function collectSchedulerMetrics(): Promise<string> {
const lines: string[] = [];
let offset = 0;
const batchSize = 100;
let hasMore = true;
while (hasMore) {
const schedulers = await metricsQueue.getJobSchedulers(offset, batchSize - 1, true);
hasMore = schedulers.length === batchSize;
offset += batchSize;
for (const s of schedulers) {
const now = Date.now();
const next = s.next ?? 0;
const isPastDue = next > 0 && next < now ? 1 : 0;
lines.push(
`bullmq_scheduler_next_execution{scheduler="${s.key}",queue="scheduled-tasks"} ${next}`,
);
lines.push(
`bullmq_scheduler_past_due{scheduler="${s.key}",queue="scheduled-tasks"} ${isPastDue}`,
);
}
}
return lines.join('\n');
}
const app = express();
app.get('/metrics', async (_req, res) => {
try {
const metrics = await collectSchedulerMetrics();
res.set('Content-Type', 'text/plain; charset=utf-8');
res.send(metrics);
} catch (err) {
res.status(500).send(
`# Error collecting scheduler metrics: ${(err as Error).message}`,
);
}
});
app.listen(9090, () => console.log('Scheduler metrics at http://localhost:9090/metrics'));
Multi-Tenant Scheduling Patterns at Scale
When multiple tenants share a single BullMQ queue, scheduler IDs must encode tenant identity to avoid collisions and enable programmatic filtering.
Tenant-Aware Scheduler Naming
Choose a naming convention with a separator that supports prefix-based lease:
const TENANT_SEPARATOR = ':';
function buildSchedulerId(tenantId: string, scheduleName: string): string {
return `${tenantId}${TENANT_SEPARATOR}${scheduleName}`;
}
function parseSchedulerId(schedulerId: string): { tenantId: string; scheduleName: string } | null {
const idx = schedulerId.indexOf(TENANT_SEPARATOR);
if (idx === -1) return null;
return {
tenantId: schedulerId.slice(0, idx),
scheduleName: schedulerId.slice(idx + 1),
};
}
Because getJobSchedulers returns all schedulers unfiltered, you must paginate and filter client-side:
async function getSchedulersForTenant(
queue: Queue,
tenantId: string,
): Promise<import('bullmq').JobScheduler[]> {
const all: import('bullmq').JobScheduler[] = [];
let offset = 0;
const batchSize = 200;
let hasMore = true;
while (hasMore) {
const batch = await queue.getJobSchedulers(offset, batchSize - 1, true);
hasMore = batch.length === batchSize;
offset += batchSize;
for (const s of batch) {
const parsed = parseSchedulerId(s.key);
if (parsed?.tenantId === tenantId) {
all.push(s);
}
}
}
return all;
}
Per-Tenant Timezone Handling
Each tenant may operate in a different timezone. The tz option flows through BullMQ's repeat pipeline: upsertJobScheduler passes it to cron-parser, which delegates to luxon.DateTime.setZone() for DST-aware resolution. Store the timezone in the scheduler itself and also in a local cache for display:
class TenantSchedulerManager {
constructor(
private readonly queue: Queue,
private readonly tenantTimezoneStore: Map<string, string>,
) {}
async upsertTenantSchedule(
tenantId: string,
record: { scheduleName: string; pattern: string; tz: string },
): Promise<void> {
const schedulerId = buildSchedulerId(tenantId, record.scheduleName);
await this.queue.upsertJobScheduler(
schedulerId,
{ pattern: record.pattern, tz: record.tz },
{
name: record.scheduleName,
data: { tenantId, scheduleName: record.scheduleName },
opts: { attempts: 3 },
},
);
this.tenantTimezoneStore.set(schedulerId, record.tz);
}
async removeTenantSchedule(tenantId: string, scheduleName: string): Promise<void> {
const schedulerId = buildSchedulerId(tenantId, scheduleName);
await this.queue.removeJobScheduler(schedulerId);
this.tenantTimezoneStore.delete(schedulerId);
}
async disableTenantSchedules(tenantId: string): Promise<number> {
const schedulers = await getSchedulersForTenant(this.queue, tenantId);
let removed = 0;
for (const s of schedulers) {
await this.queue.removeJobScheduler(s.key);
this.tenantTimezoneStore.delete(s.key);
removed++;
}
return removed;
}
}
Scheduler Partitioning Across Queues
When you have thousands of tenants with multiple schedules each, a single queue becomes a bottleneck. Partition schedulers across queues by tenant hash:
import { createHash } from 'crypto';
const PARTITION_COUNT = 4;
function partitionForTenant(tenantId: string): number {
const hash = createHash('md5').update(tenantId).digest();
return hash.readUInt32BE(0) % PARTITION_COUNT;
}
function getPartitionQueue(tenantId: string, connection: IORedis): Queue {
const partition = partitionForTenant(tenantId);
return new Queue(`scheduled-tasks-p${partition}`, { connection });
}
async function upsertPartitionedSchedule(
tenantId: string,
scheduleName: string,
pattern: string,
tz: string,
connection: IORedis,
): Promise<void> {
const queue = getPartitionQueue(tenantId, connection);
const schedulerId = buildSchedulerId(tenantId, scheduleName);
await queue.upsertJobScheduler(
schedulerId,
{ pattern, tz },
{ name: scheduleName, data: { tenantId } },
);
await queue.close();
}
Partitioning gives you independent worker scaling per partition, Redis CPU isolation (Lua scripts in one partition don't block another), and a predictable query path — given any tenant ID, you always know which queue holds its schedulers.
Performance Characteristics of Large Scheduler Fleets
Memory Cost Per Scheduler
Each job scheduler creates durable Redis keys. The approximate memory profile per scheduler is:
| Redis Key | Type | Approx Memory | Notes |
|---|---|---|---|
bull:q:repeat:{checksum} |
ZSET member | 200–400 bytes | Scheduler metadata + template hash |
bull:q:delayed member |
ZSET member | 100–200 bytes | Scored by nextMillis |
bull:q:repeat:{checksum}:{nextMillis} |
Delayed job key | 500–2000 bytes | Full job payload |
Total per active scheduler: ~800–2600 bytes. At 10,000 schedulers, that is 8–26 MB for scheduler metadata alone, plus the job payloads.
You can measure the actual overhead in your environment using Redis's MEMORY USAGE command:
import IORedis from 'ioredis';
async function measureSchedulerMemoryOverhead(
redis: IORedis,
queueName: string,
prefix = 'bull',
): Promise<Record<string, number | null>> {
const repeatKeys = await redis.keys(`${prefix}:${queueName}:repeat:*`);
const pipeline = redis.pipeline();
for (const key of repeatKeys) {
pipeline.sendCommand(
new IORedis.Command('MEMORY', ['USAGE', key], { replyEncoding: 'bytes' }),
);
}
const results = await pipeline.exec();
const memoryMap: Record<string, number | null> = {};
for (let i = 0; i < repeatKeys.length; i++) {
memoryMap[repeatKeys[i]] = (results?.[i]?.[1] as number | null) ?? null;
}
return memoryMap;
}
Enumeration Cost — Why getJobSchedulers Gets Slow
getJobSchedulers calls ZRANGE on the repeat sorted set. At 10,000 entries this is fast (~1ms). At 100,000 entries, ZRANGE combined with per-scheduler template data retrieval becomes O(N) in both Redis CPU time and network round-trips.
Strategies to reduce enumeration cost:
- Batch with small page sizes — fetch 100 at a time instead of 10,000
- Skip template data when possible — use
ZCOUNTdirectly on the repeat set via raw Redis if you only need a count - Cache scheduler metadata — maintain an application-level cache (e.g., Redis hash with TTL) updated on scheduler mutations
- Partition by queue — 2,500 schedulers per queue is faster to enumerate than 10,000 on one
Scheduler Garbage Collection
Over time, schedulers can become orphaned — the tenant deleted their account, the source code that created the scheduler is no longer deployed, or removeJobScheduler was never called. Detect and remove orphans by comparing Redis state against an authoritative list:
import { Queue, JobScheduler } from 'bullmq';
async function gcOrphanedSchedulers(
queue: Queue,
activeSchedulerIds: Set<string>,
): Promise<number> {
const orphans: JobScheduler[] = [];
let offset = 0;
const batchSize = 200;
let hasMore = true;
while (hasMore) {
const schedulers = await queue.getJobSchedulers(offset, batchSize - 1, true);
hasMore = schedulers.length === batchSize;
offset += batchSize;
for (const s of schedulers) {
if (!activeSchedulerIds.has(s.key)) {
orphans.push(s);
}
}
}
let removed = 0;
for (const orphan of orphans) {
try {
await queue.removeJobScheduler(orphan.key);
removed++;
} catch (err) {
console.error(`Failed to remove orphaned scheduler "${orphan.key}":`, err);
}
}
return removed;
}
BullMQ does NOT automatically remove scheduler records from the repeat set when they exceed their endDate or limit — it simply stops producing new jobs. Clean up expired schedulers proactively:
async function gcExpiredSchedulers(queue: Queue): Promise<number> {
const now = Date.now();
let removed = 0;
let offset = 0;
const batchSize = 200;
let hasMore = true;
while (hasMore) {
const schedulers = await queue.getJobSchedulers(offset, batchSize - 1, true);
hasMore = schedulers.length === batchSize;
offset += batchSize;
for (const s of schedulers) {
const isExpired =
(s.endDate !== undefined && s.endDate < now) ||
(s.count !== undefined && s.count <= 0);
if (isExpired) {
await queue.removeJobScheduler(s.key);
removed++;
}
}
}
return removed;
}
Decision Framework: When to Partition
| Scheduler Count | Recommended Strategy | Enumeration Latency | Redis Memory |
|---|---|---|---|
| < 1,000 | Single queue, no partition | ~50ms | < 3 MB |
| 1,000 – 10,000 | Single queue, batched enumeration | ~200ms | 3–30 MB |
| 10,000 – 50,000 | 2–4 partition queues, per-partition workers | ~100ms per partition | 30–150 MB |
| 50,000+ | 8+ partition queues, dedicated Redis shards | ~50ms per partition | 150+ MB |
Production Checklist
- Verify after upsert — call
getJobSchedulerafter creating or updating to confirm the scheduler was stored with the expected configuration - Monitor delayed set size — a growing delayed set with no corresponding active jobs indicates a worker shortage, not a scheduler problem
- Run periodic health sweeps — every 5 minutes, sample schedulers and verify their delayed job exists
- Implement scheduler GC — run
gcExpiredSchedulersdaily to prevent repeat set bloat - Avoid concurrent upserts for the same ID — race conditions can delete the delayed job without creating a new one
- Log prevMillis — include
prevMillisin structured job logs to make drift detection trivial in your log analytics tool - Set removeOnComplete on produced jobs —
removeOnComplete: 1acts as a safety net so even broken schedulers leave at most one orphan
Conclusion
Managing BullMQ schedulers at scale requires moving beyond the API reference and understanding the Redis-level mechanics: repeat keys, delayed job invariants, and enumeration costs. By applying programmatic CRUD patterns, drift-aware health monitoring, tenant-isolated naming conventions with partition routing, and proactive garbage collection, you can operate fleets of 10,000+ schedulers with confidence.
For the scheduler API basics, see our BullMQ Job Scheduling: Complete Guide. For database-backed schedule management with full audit history, see Database-Backed Dynamic Schedules with BullMQ.
Ready to monitor your BullMQ schedulers in production? QueueHub gives you a live view of all schedulers, delayed job counts, and drift metrics across queues and tenants — no instrumentation required.
Related Articles
Best BullMQ Dashboard Alternatives in 2026: A Comprehensive Comparison
Comparing every BullMQ UI option side by side: Bull Board, Arena, Taskforce, QueueHub, and raw redis-cli. Feature matrices, pricing, pros and cons, and recommendations for every team size.
QueueHub vs pg-boss: Redis vs PostgreSQL as a Job Queue Backend
pg-boss uses PostgreSQL as a Node.js job queue, while BullMQ (monitored by QueueHub) uses Redis. We compare the two approaches across throughput, persistence, transactional queues, deployment, and when transactional enqueueing matters.
QueueHub vs Temporal: Job Queues vs Workflow Orchestration
Temporal is a workflow orchestration platform — a different category from Redis-backed job queues. We compare Temporal.io against BullMQ + QueueHub, covering complexity, use cases, durability, observability, and when to choose each.