---
title: 'Task Queues and Background Jobs'
source: 'https://academia.sh/en/courses/application-layer/task-queues-and-background-jobs'
course: 'The Application Layer and Service Interaction'
language: en
updated: '2026-08-23T07:01:23+00:00'
license: 'CC BY-SA 4.0'
---

# Task Queues and Background Jobs

Measuring the removal of a long job from the request path: deriving the report job's duration from the introductory course's numbers, counting how much queuing a short request accumulates when it shares a slot pool with a long job, which measurement improves once the job moves into a separate task queue, and the scaling unit shifting from replica to worker.

Every event in the previous lesson was the equal of every other: each state event was a record
of the same size, doing the same work, lasting milliseconds. The backlog calculation rested on
that too — capacity can be expressed as a single number because the jobs are all one type. In
the same system there is a job that breaks this assumption: the period report a seller requests
scans thousands of shipment records and takes seconds. Run inside a request, such a job does not
just spend its own time — it also holds up the short requests behind it.

This lesson takes that job off the request path. **Task queues** use the same mechanics as
message queues; what differs is content — what gets written to the queue is not an event
notification but a **job** someone wants done. Worker processes, pull-based dispatch,
supervision, graceful shutdown, and recording a long job's progress in pieces were built in the
Caching, Queues and Asynchronous Processing course; none of it is retold here. What is measured
here is only the separation's **capacity payoff**: how much the short request's wait improves,
and what the design's scaling unit becomes.

## Where the Job's Length Comes From

The long job's length is not invented; it follows from K01's numbers. The Back-of-the-Envelope
Estimation lesson found 4,000 invoice lines a day, each line one seller's one day, so the seller
count is 4,000. With 400,000 daily shipments, that is 100 shipments a day per seller. Per V11
the pricing period is 30 days, so a period report scans **3,000 shipment records**. The same
lesson gives the scan rate too: 833.33 records/s, or 1.20 ms per record — the report job takes
**3.60 seconds**. All these numbers belong to the computed-value class.

The short job being compared is the tracking query, reading a single record — one scan unit. The
ratio between them is directly 3,000. The model's tick unit follows from this: a tick is 1.20
ms, and no duration inside the model is measured — each is converted from ticks.

Two assumptions are needed, and both are this course's own; neither is added to K01's table.

**K2 — morning window: 30 minutes.** Rationale: K01's V10 row says "the seller wants the report
in the morning"; report requests bunch up at the start of the business day.

**K3 — share of sellers requesting a report in the window: 0.50.** Rationale: period reports are
requested in bulk on certain days of the month, when half the sellers are assumed to check in.
Sensitivity is measured below in the 0.25 rows.

The read load comes from K01 and K02: peak reads of 416.67 requests/s, replica count 4 — the
Traffic Layer course's Session Stickiness lesson raised that number from 3 to 4. Reads per
replica come to 104.17 requests/s.

## Two Jobs in the Same Pool

The setup builds a single replica as an in-process model. The replica has a fixed number of
**slots**; each slot holds one request at a time, and once free takes the next from the front of
the queue. Arrivals come from two independent fixed-seed generators, so every design faces the
**same** request sequence. Being a model, the results are deterministic and machine-independent.

```js
// task/pool.mjs — comparing a long job staying on the request path against moving it into a
// task queue. MODEL: tick = 1.20 ms (K01: bulk job scan 833.33 records/s), arrivals come from a
// fixed-seed generator. Results are deterministic; duration is not measured, it is converted
// from ticks.
const TICK_MS = 1.2;               // K01: 1.20 ms per record
const READ_PEAK = 416.67;          // K01: peak read requests/s
const REPLICAS = 4;                // K02 session stickiness lesson: 4 replicas
const SELLERS = 4000;              // K01: daily invoice lines = number of sellers
const REPORT_RECORDS = 3000;       // K01: 100 shipments/day per seller x V11 = 30 days
const K2_WINDOW_MIN = 30;          // this course's assumption K2: morning window
const K3_RATIO = 0.5;              // this course's assumption K3: share of sellers requesting a report in the window
const SEED = 20260730;

const TICKS = Math.round((K2_WINDOW_MIN * 60 * 1000) / TICK_MS);
const trackingP = (READ_PEAK / REPLICAS) * (TICK_MS / 1000);
const reportCount = (ratio) => (SELLERS * ratio) / REPLICAS;

function generator(seed) {         // splitmix32: independent arrival sequences for two streams
  let a = seed >>> 0;
  return () => {
    a = (a + 0x9e3779b9) >>> 0;
    let z = Math.imul(a ^ (a >>> 16), 0x21f0aaad) >>> 0;
    z = Math.imul(z ^ (z >>> 15), 0x735a2d97) >>> 0;
    return ((z ^ (z >>> 15)) >>> 0) / 4294967296;
  };
}

function run({ slots, separate, workers, ratio = K3_RATIO }) {
  const reportP = reportCount(ratio) / TICKS;
  const randTracking = generator(SEED), randReport = generator(SEED + 1);
  const busy = new Array(slots).fill(0), reportBusy = new Array(workers).fill(0);
  const queue = [], tasks = [], wait = [], reportDuration = [];
  let reportsArrived = 0;
  for (let t = 0; t < TICKS; t += 1) {
    if (randTracking() < trackingP) queue.push({ t, duration: 1 });
    if (randReport() < reportP) { reportsArrived += 1; (separate ? tasks : queue).push({ t, duration: REPORT_RECORDS, report: true }); }
    for (let i = 0; i < slots; i += 1) {
      if (busy[i] > 0) busy[i] -= 1;
      if (busy[i] === 0 && queue.length > 0) {
        const job = queue.shift();
        if (job.report) reportDuration.push(t - job.t + job.duration); else wait.push(t - job.t);
        busy[i] = job.duration;
      }
    }
    for (let i = 0; i < workers; i += 1) {
      if (reportBusy[i] > 0) reportBusy[i] -= 1;
      if (reportBusy[i] === 0 && tasks.length > 0) {
        const job = tasks.shift();
        reportDuration.push(t - job.t + job.duration);
        reportBusy[i] = job.duration;
      }
    }
  }
  const sorted = [...wait].sort((a, b) => a - b);
  const percentile = (p) => sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))];
  return { tracking: wait.length, p50: percentile(50), p99: percentile(99), longest: sorted[sorted.length - 1],
    late: wait.filter((w) => w * TICK_MS > 100).length,
    reportsArrived, completed: reportDuration.length,
    reportAvg: reportDuration.reduce((a, b) => a + b, 0) / reportDuration.length };
}

console.log(`tick = ${TICK_MS} ms, window ${K2_WINDOW_MIN} min = ${TICKS} ticks, ${REPLICAS} replicas`);
console.log(`per replica tracking ${(READ_PEAK / REPLICAS).toFixed(2)} requests/s, ` +
  `K3 = ${K3_RATIO} -> expected reports per replica ${reportCount(K3_RATIO)}`);
console.log(`report job = ${REPORT_RECORDS} records = ${(REPORT_RECORDS * TICK_MS / 1000).toFixed(2)} s\n`);

const row = (name, r) => console.log(`${name.padEnd(24)} ${String(r.tracking).padStart(7)} ` +
  `${(r.p50 * TICK_MS).toFixed(2).padStart(9)} ${(r.p99 * TICK_MS).toFixed(2).padStart(9)} ` +
  `${(r.longest * TICK_MS).toFixed(0).padStart(11)} ${String(r.late).padStart(9)} ` +
  `${(r.late / r.tracking).toFixed(4).padStart(6)} ${`${r.completed}/${r.reportsArrived}`.padStart(9)} ` +
  `${(r.reportAvg * TICK_MS / 1000).toFixed(2).padStart(9)}`);

console.log("design                    tracking  p50 (ms)  p99 (ms)  longest(ms)  >100 ms     rate  done/arrived  report (s)");
for (const slots of [2, 4, 8]) row(`shared s=${slots}`, run({ slots, separate: false, workers: 0 }));
for (const slots of [2, 4]) row(`shared s=${slots}, K3=0.25`, run({ slots, separate: false, workers: 0, ratio: 0.25 }));
for (const slots of [2, 4]) row(`separate s=${slots}, w=2`, run({ slots, separate: true, workers: 2 }));

const fewest = (make) => { for (let s = 1; s <= 12; s += 1) if (make(s).late === 0) return s; return 0; };
const sharedSlots = fewest((slots) => run({ slots, separate: false, workers: 0 }));
const separateSlots = fewest((slots) => run({ slots, separate: true, workers: 2 }));
const reportSeconds = reportCount(K3_RATIO) * REPLICAS * (REPORT_RECORDS * TICK_MS / 1000);
const workers = reportSeconds / (K2_WINDOW_MIN * 60);
console.log(`\nfewest slots that zero out tracking wait above 100 ms: shared ${sharedSlots}, separate ${separateSlots}`);
console.log(`scaling unit -> shared ${sharedSlots * REPLICAS} slots (all on the read path), ` +
  `separate ${separateSlots * REPLICAS} slots + task workers`);
console.log(`system-wide report work = ${reportSeconds.toFixed(0)} worker-seconds / ` +
  `${K2_WINDOW_MIN * 60} s = ${workers.toFixed(2)} workers (utilization 1.00)`);
```

```
tick = 1.2 ms, window 30 min = 1500000 ticks, 4 replicas
per replica tracking 104.17 requests/s, K3 = 0.5 -> expected reports per replica 500
report job = 3000 records = 3.60 s

design                    tracking  p50 (ms)  p99 (ms)  longest(ms)  >100 ms     rate  done/arrived  report (s)
shared s=2                187459      0.00   5887.20        9398     54392 0.2902   457/457      4.24
shared s=4                187459      0.00    634.80        2808      3088 0.0165   457/457      3.61
shared s=8                187459      0.00      0.00           0         0 0.0000   457/457      3.60
shared s=2, K3=0.25       187459      0.00   2766.00        4984     15204 0.0811   223/223      3.77
shared s=4, K3=0.25       187459      0.00      0.00        1240       293 0.0016   223/223      3.60
separate s=2, w=2         187459      0.00      0.00           0         0 0.0000   457/457      4.14
separate s=4, w=2         187459      0.00      0.00           0         0 0.0000   457/457      4.14

fewest slots that zero out tracking wait above 100 ms: shared 7, separate 1
scaling unit -> shared 28 slots (all on the read path), separate 4 slots + task workers
system-wide report work = 7200 worker-seconds / 1800 s = 4.00 workers (utilization 1.00)
```

In thirty minutes, 187,459 tracking requests arrived, and against an expected 500 reports the
seed produced 457 — one realization of the Bernoulli arrival, the same across every row.

## Low Utilization, Long Queue

The shared pool's numbers look inconsistent at first glance. In a two-slot replica, report work
uses less than half the slots: 457 reports × 3.60 seconds = 1,645 slot-seconds, 46 percent of
the 3,600 slot-seconds two slots provide over a thirty-minute window. The tracking stream wants
only 12.5 percent. Total utilization is under 60 percent, so the pool has enough capacity.

Yet **29.02 percent** of tracking requests wait longer than 100 ms, p99 wait is 5887 ms, the
longest wait 9398 ms. Why sufficient capacity can still produce this table is clear: the queue
drains in order, and if the job ahead takes 3,000 ticks, a one-tick job behind it waits 3,000
ticks too. When two reports run at once, both slots close, and every tracking request arriving
during that span joins the line. **A queue can grow long while average utilization stays low
because of how job durations are distributed; a single utilization number hides that
distribution.**

Adding slots fixes it, but expensively. At four slots the rate drops to 1.65 percent, p99 to
634.80 ms; at eight it hits zero. The threshold is measured: the fewest slots that zero out
waits above 100 ms is **seven**. The short request alone needs one slot — that is the number
the separate design measures. So six slots exist purely to keep it from queuing behind the long
job.

K3's sensitivity points the same way. Halving the share of sellers requesting a report drops the
affected-request rate from 29.02 to 8.11 percent at two slots, and from 1.65 to 0.16 percent at
four — but it does **not reach zero**. Lower demand reduces how often the problem occurs, not
its structure; even one long job holds up the request behind it for its own duration.

## The Scaling Unit

In the separate design, the report job goes into the queue and the caller returns with a job
ID. Tracking measurements come out the same at every slot count: p99 zero, longest wait zero,
nothing waiting above 100 ms. Pull the long job off the path, and the short request's queue
disappears with it.

The cost falls on the report side, and it is measured: with two workers, the average report
duration is **4.14 seconds** instead of 3.60 — it now waits in its own queue. The gap is 15
percent and closes by adding workers if wanted, because worker count is now independent of the
tracking stream.

The real result is in the last three lines. In the shared design, zeroing out the tracking queue
needs seven slots per replica — **28 slots** across four replicas. All of them sit on the read
path, each configured to accept tracking requests in the replicas where read traffic is
distributed. In the separate design, one slot per replica suffices for tracking — **4 slots**
across four replicas — and the report job moves to its own workforce. That workforce's size is
arithmetic too: system-wide, 2,000 reports × 3.60 seconds = 7,200 worker-seconds, which over the
thirty-minute window comes to **4.00 workers** — utilization of 1.00, so a real design would
leave some margin.

**Separation's gain is not a number, it is independence.** In the shared design, growing report
demand means growing the replica, and a bigger replica also grows the read path, though the
read path never needed that capacity. In the separate design, report demand moves worker count,
read demand moves replica count, and the two never look at each other. The peak read rate from
the introductory course, 416.67 requests/s, has not changed; what changed is how large the
replicas serving that rate have to be — from seven slots per replica down to one.

## Summary

- The long job's length is derived from K01: 100 shipments/day per seller × 30 days = 3,000
  records, which at a scan rate of 833.33 records/s takes 3.60 seconds; the tracking request is,
  on that same scale, a single record.
- In the shared pool, even with utilization under 60 percent, 29.02 percent of tracking requests
  wait longer than 100 ms, with a p99 of 5887 ms: a single utilization number hides the
  distribution of job durations.
- The cost of zeroing out the queue is slots: the fewest slots that eliminate waits above 100 ms
  is seven in the shared design, one in the separate design.
- Halving the K3 assumption drops the affected-request rate from 29.02 to 8.11 percent, but it
  does not reach zero; demand changes how often the problem occurs, not its structure.
- In the separate design, tracking wait is zero at every slot count; the cost paid is the report
  duration rising from 3.60 to 4.14 seconds with two workers.
- The scaling unit splits apart: the shared design needs 28 slots across four replicas, the
  separate design needs 4 slots plus 4.00 workers system-wide, and the two numbers move
  independently of each other.

## Next Step

The "4.00 workers" in the last line is not a capacity but a lower bound; at utilization of
exactly one, the queue never drains. The number itself leaves a question open: does adding a
worker really translate into addition? The previous lesson's backlog table treated consumer
capacity as a single number and never asked where it came from — yet capacity is the product of
consumer count and per-consumer rate, and one of those factors does not grow forever. The next
lesson takes up multiple consumers pulling from the same queue: it measures each added
consumer's contribution to drain time, where that contribution stops, and what breaks in
exchange for the throughput gained.
