Build the Same Order Pipeline with BullMQ and BeeQueue — A Hands-On Code Tutorial
Every Node.js developer evaluating job queues eventually lands on the same two Redis-backed libraries: BullMQ and BeeQueue. And every comparison you find shows you a feature table — checkmarks for priorities, retries, flows, rate limiting — and tells you "use BullMQ for complex workloads, BeeQueue for simple ones."
That's not what this post does.
Here, you're going to build the same real-world order-processing pipeline in both libraries, side by side. You'll write the TypeScript code yourself (or follow along). You'll see how each library models queues, enqueues jobs, processes work, handles errors, and exposes observability. By the time you finish, you'll understand the feel of each API — which is far more valuable than a comparison table.
The pipeline we're building is a four-stage e-commerce order processor:
- Validate — check inventory and fraud score
- Charge — call a payment gateway
- Fulfill — send to warehouse, generate a tracking number
- Notify — email the customer a receipt
We'll implement it once with BullMQ (TypeScript-native, feature-rich, ~180 KB on npm) and once with BeeQueue (minimalist, ~40 KB, higher raw throughput). Both connect to the same local Redis. Both get the same JSON payloads. Both handle errors and retries.
Let's write some code.
Project Setup — Same Scaffold, Two Dependencies
Prerequisites
You'll need Node.js 18+, a running Redis server (Redis 6+), and two empty project directories:
mkdir bullmq-pipeline beequeue-pipeline
cd bullmq-pipeline && npm init -y
cd ../beequeue-pipeline && npm init -y
If you don't have Redis running locally, the quickest way is Docker:
docker run -p 6379:6379 redis:7
Install BullMQ
cd bullmq-pipeline
npm install bullmq ioredis
npm install -D typescript @types/node
BullMQ bundles its own TypeScript declarations, and it ships with first-class generic support for job data and return types. We'll see that pay off in a minute.
Install BeeQueue
cd beequeue-pipeline
npm install bee-queue ioredis
npm install -D typescript @types/node @types/bee-queue
BeeQueue itself is pure JavaScript — TypeScript declarations come from the community @types/bee-queue package. The npm download for bee-queue is around 40 KB compared to BullMQ's ~180 KB, but that's only meaningful if you're deploying to a serverless function with strict cold-start budgets. For most server-side workloads, both are negligible.
Both libraries need a running Redis — there's no embedded storage. The Redis key prefixes differ (bullmq: for BullMQ, bq: for BeeQueue), which means you can actually run both libraries against the same Redis instance without key collisions. We'll exploit that later with QueueHub Studio.
Step 1 — Creating the Queue
BullMQ — Separate Queue and Worker Classes
BullMQ enforces a clean architectural separation between producers and consumers right at the API level. You use Queue to add jobs, Worker to process them, and QueueEvents to listen for global lifecycle events. This maps naturally to microservice architectures where different services read and write the same queue.
// bullmq-pipeline/src/queue.ts
import { Queue, Worker, QueueEvents } from "bullmq";
import Redis from "ioredis";
const connection = new Redis({
host: "localhost",
port: 6379,
maxRetriesPerRequest: null,
});
// Producer-side — just manages jobs, no processing logic
export const orderQueue = new Queue("orders", {
connection,
defaultJobOptions: {
attempts: 3,
backoff: { type: "exponential", delay: 2000 },
removeOnComplete: { count: 100 },
removeOnFail: { count: 50 },
},
});
// Global event listener — works across all workers and processes
export const queueEvents = new QueueEvents("orders", { connection });
The defaultJobOptions object is a BullMQ standout — you set retry policy, TTL, and cleanup rules once at queue creation. Every job inherits them, and individual jobs can override specific fields at enqueue time.
BeeQueue — One Queue Class Does Both
BeeQueue uses a single Queue class for everything. You control behavior through constructor flags:
// beequeue-pipeline/src/queue.ts
import BeeQueue from "bee-queue";
// Producer queue (no processing, no event listening from this instance)
export const orderQueue = new BeeQueue("orders", {
redis: { host: "localhost", port: 6379 },
isWorker: false,
getEvents: false,
sendEvents: false,
storeJobs: true,
});
// beequeue-pipeline/src/worker.ts
import BeeQueue from "bee-queue";
// Worker queue — BeeQueue auto-duplicates the Redis client for blocking commands
export const orderWorkerQueue = new BeeQueue("orders", {
redis: { host: "localhost", port: 6379 },
isWorker: true,
stallInterval: 5000,
});
The design difference here tells you everything about each library's philosophy. BullMQ says: "Your producer and consumer are different concerns — we give you different classes to express that." BeeQueue says: "It's all just a queue — configure it how you need it."
Neither is wrong. BullMQ's approach makes it harder to accidentally run a producer where a worker is expected; BeeQueue's approach is simpler to learn and has fewer imports.
Critical detail: BeeQueue's stallInterval is opt-in (defaults to 5 seconds). BullMQ handles stall detection automatically inside the Worker class via Redis heartbeat keys. If you set stallInterval too high in BeeQueue and a worker crashes while processing a job, it takes that long for another worker to pick up the stalled job. BullMQ's stalledInterval option on the Worker gives you similar control but defaults to a sensible 30 seconds with automatic re-processing.
Step 2 — Enqueuing Orders (Producer)
BullMQ — queue.add(name, data, opts)
BullMQ uses a flat functional signature: pass a job name, the data payload, and optional configuration. The name is crucial — it lets a single queue route different job types to different handlers (though for this pipeline we'll use one name for simplicity).
// bullmq-pipeline/src/producer.ts
import { orderQueue } from "./queue.js";
interface OrderPayload {
orderId: string;
customerEmail: string;
items: Array<{ sku: string; qty: number; price: number }>;
total: number;
}
async function placeOrder(order: OrderPayload): Promise<string> {
const job = await orderQueue.add("processOrder", order, {
// Per-job overrides
attempts: 5,
priority: order.total > 500 ? 1 : 10,
delay: order.items.some((i) => i.sku.startsWith("PREORDER"))
? 7 * 24 * 60 * 60 * 1000
: 0,
});
console.log(`[BullMQ] Order ${order.orderId} queued as job ${job.id}`);
return job.id!;
}
// Usage
placeOrder({
orderId: "ORD-001",
customerEmail: "alice@example.com",
items: [{ sku: "WIDGET-1", qty: 2, price: 29.99 }],
total: 59.98,
});
The queue.add signature takes full advantage of TypeScript generics: Queue<TData, TReturn, TName>. The job name ("processOrder") acts as a discriminator — you can have one queue handle multiple job types, each with its own processor. Priorities are a first-class concept (values 1–255, lower is higher priority). The delay parameter supports absolute timestamps or relative milliseconds.
BeeQueue — queue.createJob(data).save()
BeeQueue uses a builder pattern. Each property is a method call, which makes individual options self-documenting at the cost of verbosity.
// beequeue-pipeline/src/producer.ts
import { orderQueue } from "./queue.js";
interface OrderPayload {
orderId: string;
customerEmail: string;
items: Array<{ sku: string; qty: number; price: number }>;
total: number;
}
async function placeOrder(order: OrderPayload): Promise<string> {
const job = orderQueue
.createJob(order)
.setId(order.orderId)
.retries(5)
.backoff("exponential", 2000);
// BeeQueue has no native priority — need separate queues per tier
// For preorder delays, BeeQueue supports .delayUntil(Date|timestamp)
if (order.items.some((i) => i.sku.startsWith("PREORDER"))) {
job.delayUntil(Date.now() + 7 * 24 * 60 * 60 * 1000);
}
await job.save();
// Producer-side events — attach directly to the Job object
job.on("succeeded", (result) => {
console.log(`[BeeQueue] Order ${order.orderId} completed with:`, result);
});
job.on("failed", (err) => {
console.error(`[BeeQueue] Order ${order.orderId} failed:`, err.message);
});
console.log(`[BeeQueue] Order ${order.orderId} queued as job ${job.id}`);
return job.id;
}
The builder chain — .createJob(data).retries(5).backoff("exponential", 2000).save() — reads almost like a sentence. The .setId(order.orderId) call is BeeQueue's way of setting custom job IDs (BullMQ uses opts.jobId). One thing BeeQueue does that BullMQ doesn't: it lets you attach event listeners directly to the Job object on the producer side. You can know immediately when your specific job succeeds or fails. In BullMQ, you'd need QueueEvents and filter by jobId — more scalable for high-volume production, but less convenient for ad-hoc scripts.
The biggest gap here is priorities. BullMQ has first-class priority support built into the queue — higher-priority jobs get consumed first regardless of insertion order. BeeQueue has none. If your order pipeline needs "expedite" handling for premium customers, you'd build separate queues per priority tier in BeeQueue and manage them in your worker code.
Step 3 — Processing Jobs (Worker)
BullMQ — Async Processor with Return Values
BullMQ's Worker takes a queue name, an async handler function, and worker options. The handler receives a typed Job<TData, TReturn> and whatever it returns becomes the job's .returnvalue.
// bullmq-pipeline/src/worker.ts
import { Worker, UnrecoverableError } from "bullmq";
import { connection } from "./queue.js";
const worker = new Worker<OrderPayload, string>(
"orders",
async (job) => {
const { orderId, items, total, customerEmail } = job.data;
console.log(
`[BullMQ] Processing order ${orderId} (attempt ${job.attemptsMade + 1})`
);
// Stage 1: Validate
await job.updateProgress(20);
if (!items.length)
throw new UnrecoverableError("Order has no items");
// Stage 2: Charge payment
await job.updateProgress(40);
const chargeResult = await chargePayment(orderId, total);
job.log(`Payment charged: ${chargeResult.transactionId}`);
// Stage 3: Fulfill
await job.updateProgress(70);
const trackingNumber = await fulfillOrder(orderId, items);
job.log(`Fulfilled — tracking: ${trackingNumber}`);
// Stage 4: Notify
await job.updateProgress(90);
await sendEmail(customerEmail, orderId, trackingNumber);
await job.updateProgress(100);
return trackingNumber;
},
{
connection,
concurrency: 5,
lockDuration: 60_000,
stalledInterval: 30_000,
maxStalledCount: 3,
}
);
worker.on("completed", (job, returnvalue) => {
console.log(
`[BullMQ] ✅ Order ${job.data.orderId} done — tracking: ${returnvalue}`
);
});
worker.on("failed", (job, err) => {
console.error(
`[BullMQ] ❌ Order ${job?.data?.orderId} failed: ${err.message}`
);
});
worker.on("error", (err) => {
console.error("[BullMQ] Worker error:", err.message);
});
// Graceful shutdown
async function shutdown() {
await worker.close();
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
BeeQueue — queue.process(concurrency, handler)
BeeQueue's processing API is a method on the same Queue class. The handler receives a Job and returns a value that becomes the "done" result.
// beequeue-pipeline/src/worker.ts
import { orderWorkerQueue } from "./queue.js";
orderWorkerQueue.process(5, async (job) => {
const { orderId, items, total, customerEmail } = job.data;
console.log(`[BeeQueue] Processing order ${orderId}`);
// Stage 1: Validate
job.reportProgress(20);
if (!items.length) throw new Error("Order has no items");
// Stage 2: Charge payment
job.reportProgress(40);
const chargeResult = await chargePayment(orderId, total);
// Stage 3: Fulfill
job.reportProgress(70);
const trackingNumber = await fulfillOrder(orderId, items);
// Stage 4: Notify
job.reportProgress(90);
await sendEmail(customerEmail, orderId, trackingNumber);
job.reportProgress(100);
return trackingNumber;
});
orderWorkerQueue.on("succeeded", (job, result) => {
console.log(
`[BeeQueue] ✅ Order ${job.data.orderId} done — tracking: ${result}`
);
});
orderWorkerQueue.on("retrying", (job, err) => {
console.log(
`[BeeQueue] 🔄 Retrying order ${job.data.orderId} (${err.message})`
);
});
orderWorkerQueue.on("failed", (job, err) => {
console.error(`[BeeQueue] ❌ Order ${job.data.orderId} failed: ${err.message}`);
});
// Stall detection — must be called externally
setInterval(() => {
orderWorkerQueue.checkStalledJobs(5000, (err, stalled) => {
if (stalled)
console.log(`[BeeQueue] Re-enqueued ${stalled} stalled jobs`);
});
}, 5000);
async function shutdown() {
await orderWorkerQueue.close(30_000);
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
Both implementations are doing the same thing: four stages, progress reporting, return a tracking number. But the differences matter:
-
job.updateProgress()(BullMQ) vsjob.reportProgress()(BeeQueue) — identical concept, different method name. Easy to remember if you're migrating, but the kind of thing that'll annoy you more than once during a switch. -
job.log()— BullMQ appends log lines that are stored in Redis and retrievable per-job. BeeQueue has no equivalent. In a production pipeline,job.log()is invaluable for debugging a specific failed order without digging through application logs. -
UnrecoverableError— BullMQ lets you signal "don't retry this, it's permanently broken." When an order has zero items, retrying won't help. BeeQueue's only option is to throw, which triggers retries until the counter expires. You can work around it (check data validity before processing and don't re-throw), but the library doesn't help you. -
Stall detection — BullMQ handles this automatically inside the
Workervia Redis heartbeat keys. BeeQueue requires you to manually callcheckStalledJobs()on an interval. In a production deployment, this is one more thing to monitor and tune. Forgettable, but critical if a worker crashes mid-job. -
Concurrency — Both support it, but BullMQ puts it in the constructor options (
concurrency: 5) while BeeQueue passes it as the first argument to.process(5, handler).
Step 4 — Error Handling and Retries In Depth
BullMQ — Built-in Backoff with Jitter and Custom Strategies
// Queue-wide default (set at queue creation)
// attempts: 3, exponential backoff starting at 2s
// Per-job override
await orderQueue.add("processOrder", order, {
attempts: 5,
backoff: {
type: "exponential",
delay: 1000,
jitter: 0.5, // randomize ±50% to avoid thundering herd
},
});
// Stop retrying — UnrecoverableError
if (job.data.total < 0) {
throw new UnrecoverableError("Negative total — data corruption");
}
// Custom backoff strategy
const worker = new Worker("orders", processor, {
settings: {
backoffStrategy: (attemptsMade: number, type: string, err: Error) => {
if (err.message.includes("rate-limit")) {
return 60_000; // wait 1 min for rate limits
}
return attemptsMade * 2_000;
},
},
});
// Manual retry from dashboard or API
import { Job } from "bullmq";
const failedJob = await Job.fromId(orderQueue, "job-id");
await failedJob.retry("failed", { resetAttemptsMade: true });
BeeQueue — Simpler Retry Model
// Configured at job creation
orderQueue
.createJob(order)
.retries(3)
.backoff("exponential", 2000)
.save();
// No built-in UnrecoverableError — throw to fail, don't retry beyond max
// Workaround: validate before processing
function processOrder(job: BeeQueue.Job<OrderPayload>) {
if (job.data.items.length === 0) {
console.error("Invalid data — returning without retry");
return null; // resolves as success, avoids retry
}
// ... actual processing
}
// Manual retry via re-creating the job
queue.on("failed", (job, err) => {
if (isTransient(err)) {
orderQueue.createJob(job.data).save();
}
});
// Timeout per-job
orderQueue.createJob(order).timeout(30_000).save();
The retry model is where BullMQ pulls ahead noticeably. Three specific capabilities are hard to replicate in BeeQueue:
-
Jitter — BullMQ's
backoff.jitter: 0.5randomizes each retry delay by ±50%. When 200 payment gateway calls fail simultaneously, jitter prevents all 200 from retrying at the exact same moment — the "thundering herd" problem. BeeQueue has no jitter. -
Custom backoff strategies — Need to back off 60 seconds on 429 rate limits but 5 seconds on 500 errors? BullMQ's
backoffStrategycallback lets you inspect the error object and return any delay you want. BeeQueue supports only"fixed"and"exponential". -
UnrecoverableError— This is genuinely useful. An order with negative total should never be retried. In BullMQ, you throwUnrecoverableErrorand the job moves tofailedimmediately. In BeeQueue, you have to work around it by catching errors internally and returning a sentinel value.
For the majority of pipelines — where errors are transient network issues and retries are just "try again in a few seconds" — BeeQueue's simpler model covers 80% of the use case. But that remaining 20% gets increasingly expensive as your pipeline grows.
Step 5 — Observability and Monitoring
BullMQ — QueueEvents + Built-in Job Counts
import { QueueEvents } from "bullmq";
const events = new QueueEvents("orders", { connection });
events.on("completed", ({ jobId, returnvalue }) => {
console.log(`[Monitor] Job ${jobId} completed: ${returnvalue}`);
});
events.on("failed", ({ jobId, failedReason }) => {
console.error(`[Monitor] Job ${jobId} failed: ${failedReason}`);
});
events.on("progress", ({ jobId, data }) => {
console.log(`[Monitor] Job ${jobId} progress: ${data}`);
});
events.on("stalled", ({ jobId }) => {
console.warn(`[Monitor] Job ${jobId} stalled!`);
});
// Job counts — one call, all states
const counts = await orderQueue.getJobCounts(
"waiting", "active", "completed", "failed", "delayed"
);
console.log(counts);
// { waiting: 12, active: 3, completed: 450, failed: 2, delayed: 8 }
// Paginated job retrieval
const failedJobs = await orderQueue.getJobs(["failed"], 0, 20);
for (const job of failedJobs) {
console.log(`${job.id}: ${job.failedReason} (attempts: ${job.attemptsMade})`);
}
BullMQ's QueueEvents is a separate class that subscribes to Redis PubSub channels and emits typed events. Because it's a separate class, you can run a monitoring process completely independent from your workers and producers — perfect for a centralized observability dashboard.
The getJobCounts() method makes a single Redis call and returns counts for all job states at once. Contrast with BeeQueue, where you must query each state individually.
BeeQueue — PubSub Events + Manual Count Queries
const monitorQueue = new BeeQueue("orders", {
redis: { host: "localhost", port: 6379 },
isWorker: false,
getEvents: true,
sendEvents: false,
});
monitorQueue.on("job succeeded", (jobId, jobResult) => {
console.log(`[Monitor] Job ${jobId} succeeded:`, jobResult);
});
monitorQueue.on("job failed", (jobId, err) => {
console.error(`[Monitor] Job ${jobId} failed:`, err.message);
});
monitorQueue.on("job progress", (jobId, progress) => {
console.log(`[Monitor] Job ${jobId} progress:`, progress);
});
// Job counts — BeeQueue has no built-in getJobCounts()
async function getBeeQueueCounts(queue: BeeQueue) {
const [waiting, active, succeeded, failed, delayed] = await Promise.all([
queue.getJobs("waiting", { size: 0 }),
queue.getJobs("active", { size: 0 }),
queue.getJobs("succeeded", { size: 0 }),
queue.getJobs("failed", { size: 0 }),
queue.getJobs("delayed", { size: 0 }),
]);
return {
waiting: (waiting as any).length ?? 0,
active: (active as any).length ?? 0,
succeeded: (succeeded as any).length ?? 0,
failed: (failed as any).length ?? 0,
delayed: (delayed as any).length ?? 0,
};
}
// Paginated job retrieval
const failedJobs = await monitorQueue.getJobs("failed", { start: 0, end: 19 });
for (const job of failedJobs) {
console.log(`${job.id}: status=${job.status} progress=${job.progress}`);
}
Two naming differences to watch for: BeeQueue uses "succeeded" and "failed" (past-tense adjectives), while BullMQ uses "completed" and "failed". For PubSub events, BeeQueue uses space-delimited names like "job succeeded" — easy to miss when you're used to BullMQ's "completed".
Viewing Both in QueueHub Studio
If you're thinking "I don't want to build a monitoring UI for either of these," you're in good company. QueueHub Studio (queuehub.tech) is a SaaS dashboard that supports both BullMQ and BeeQueue backends in the same interface. Point it at your Redis URL, and it auto-detects which library each queue uses based on the Redis key prefix (bullmq: vs bq:).
For both libraries, QueueHub gives you:
- Dashboard overview — real-time job counts per queue (waiting, active, completed, failed, delayed) with throughput charts
- Job detail view — inspect the full data payload, progress percentage, attempt count, error stack traces, and timestamps
- Worker view — see active workers, their concurrency settings, and currently processing jobs
- Queue management — pause, resume, and clean jobs by state with one click
BullMQ users get extras: per-job log output from job.log(), return values on completed jobs, and flow DAG visualization for parent-child job chains. BeeQueue users get the custom job IDs and creator-side event data that BeeQueue tracks.
The best part: during a migration from BeeQueue to BullMQ, you can monitor both in a single dashboard, verifying that the new pipeline processes correctly before you turn off the old one.
Step 6 — Migration: Moving from BeeQueue to BullMQ
If you followed this tutorial and realized BullMQ fits your growing pipeline better, here's the pragmatic 5-step migration strategy.
Step 1: Start with a new queue name. Create orders-v2 in BullMQ so both libraries coexist:
const newQueue = new Queue("orders-v2", { connection });
Step 2: Dual-write. Update your producer to enqueue jobs to both orders (BeeQueue) and orders-v2 (BullMQ):
async function placeOrder(order: OrderPayload) {
await Promise.all([
beeQueue.createJob(order).save(), // old path
bullQueue.add("processOrder", order), // new path
]);
}
Step 3: Validate. Run your BullMQ workers alongside existing BeeQueue workers. Compare results for the same input data. QueueHub Studio makes this trivial — you can see both queues side by side.
Step 4: Cut over. Stop the BeeQueue producers, then let the BeeQueue workers drain. Stop the BeeQueue workers once getBeeQueueCounts() shows zero active and waiting jobs.
Step 5: Clean up. Drain the BeeQueue and delete its Redis keys:
await oldBeeQueue.drain();
// Delete BeeQueue keys: KEYS bq:orders:* via Redis CLI
The code diff is straightforward — here's the cheat sheet:
| Concern | BeeQueue | BullMQ |
|---|---|---|
| Import | BeeQueue from "bee-queue" |
{ Queue, Worker } from "bullmq" |
| Create queue | new BeeQueue("orders", { redis }) |
new Queue("orders", { connection }) |
| Add job | .createJob(data).retries(3).save() |
.add("name", data, { attempts: 3 }) |
| Process | queue.process(handler) |
new Worker("orders", handler) |
| Progress | job.reportProgress(50) |
job.updateProgress(50) |
| Job logs | Not available | job.log("message") |
| Stall detection | Manual checkStalledJobs() |
Built-in Worker heartbeat |
| Priority | Not supported | { priority: 1 } |
| TypeScript | Community @types/bee-queue |
First-class generics |
Most teams I've seen complete this migration in three to five days, with the bulk of the time going to refactoring error handling and adding monitoring that wasn't practical in BeeQueue.
Conclusion — Build the Pipeline, Then Decide
Here's what we built: a four-stage order-processing pipeline in both BullMQ and BeeQueue. We created queues, enqueued jobs, processed work with progress tracking, handled errors with retries, and set up observability. The code is similar enough that you can follow one after reading the other, but different enough that the API design philosophy shines through.
BullMQ gives you more — priorities, custom backoff with jitter, UnrecoverableError, built-in stall detection, job.log(), first-class TypeScript, and a rich event system. Its API is larger and its package is bigger, but every feature exists because people building real pipelines needed it.
BeeQueue gives you less — and that's a feature. The API is smaller, the package is tiny, and for a simple pipeline it gets out of your way. The builder pattern makes job creation self-documenting. If your order processing has two stages, standard retries, and no priority tiers, BeeQueue is genuinely pleasant to work with.
The right choice isn't about "complex vs simple." It's about whether your pipeline's needs fit comfortably within BeeQueue's API or push against its limits. If you find yourself writing workarounds for priorities, stall detection, or retry jitter, that's a signal — not a failure of BeeQueue, but a signal that your pipeline has grown up and might benefit from BullMQ's richer contract.
Either way, QueueHub Studio will monitor it. Head over to queuehub.tech, connect your Redis, and see your queues live — BullMQ, BeeQueue, or both. The full working code from this tutorial is available at github.com/queuehub/bullmq-vs-beequeue-pipeline — fork it, run it, and decide which library feels right for your next pipeline.
Related Articles
Testing and Development Workflows for BullMQ vs BeeQueue
A practical guide to testing job queue code with BullMQ and BeeQueue — test fixtures, producer and worker testing patterns, local development workflows, and CI integration.
BeeQueue Production Patterns and Redis Internals: Beyond the Basics
Go beyond basic BeeQueue setup. Master Redis key internals, Prometheus health checks, dead letter queues, graceful shutdown, batch processing, and benchmarking methodology.
Beyond Static Priority — Building a Dynamic Priority Escalation Engine with BullMQ
Learn how to build a dynamic priority escalation engine for BullMQ with three production-ready patterns: configurable escalation rules, multi-factor priority scoring, and priority-weighted worker pool routing with complete TypeScript code.