---
title: 'Edge Queues and Scheduled Jobs'
source: 'https://academia.sh/en/courses/serverless/edge-queues-and-scheduled-jobs'
course: 'Serverless and Edge Computing'
language: en
updated: '2026-09-13T05:07:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "Runs the same job set on two queues and counts three things separately: queue-entry trip, where the job runs, the result's return path"
    - "Measures the edge queue's four constraints with a number and writes the cost of working around each one"
    - 'Shows which region scheduled triggering runs in and whose job the time-zone conversion is'
---

# Edge Queues and Scheduled Jobs

The same job set is processed in the center queue and the edge queue; the queue-entry trip, where the job runs, and the result's return path are counted separately. The edge queue's four constraints and the region a scheduled job fires in are measured.

The previous lesson managed to hold coordinated state at the edge: the single-writer object
queued up writes arriving for its own key. Asynchronous work, though, still runs in the center;
validation, invoice lines, and field notifications get written to a queue in the center region.
This lesson runs the same job set in two places and counts three things separately: the
queue-entry trip, where the job runs, and the result's return path.

The measurement runs on the six branch edges of the fictional **measurement network** and is a
**model**: the edges, the two queue services, and the scheduler are set up with `node`. No real
product is called.

**EC25.** For every edge, the one-way latency to the center and to the nearest edge location is
given, along with a nightly read count.
**EC26.** The queue-entry trip is twice the one-way latency plus the queue's fixed acceptance time.
**EC27.** The center queue offers a batch read of 250, 10 retries, 14 days of retention, and
ordering per key; the edge queue offers 25 and 3, retains for 4 days, and gives no ordering.
**EC28.** The consumer starts once per batch read: 40 ms in the center, 6 ms at the edge.
Processing per read is 12 ms on both paths.
**EC29.** The result is written to the store where the job runs. The center store is as far away
as the center trip; the edge key-value store is read locally, but its write takes 480 ms to
become visible at other edges.
**EC30.** Scheduled triggering knows only the base clock; the edge scheduler chooses the trigger
location itself, and the choice is not recorded.

## The Same Job Set, Two Queues

```js
// network.mjs — fictional measurement network model: edges, latencies, the two queues' constraints.
export const EDGES = [                              // one-way latency ms, band offset from base clock
  { name: "edge-west",     center: 12, edge: 5,  band: 0, reads: 610 },
  { name: "edge-north",    center: 38, edge: 7,  band: 0, reads: 520 },
  { name: "edge-east",     center: 74, edge: 9,  band: 2, reads: 430 },
  { name: "edge-south",    center: 51, edge: 6,  band: 1, reads: 350 },
  { name: "edge-highland", center: 96, edge: 11, band: 3, reads: 280 },
  { name: "edge-port",     center: 63, edge: 8,  band: 2, reads: 210 },
];
export const TOTAL = EDGES.reduce((t, u) => t + u.reads, 0);
export const QUEUE = {                              // constraints, item by item
  center: { accept: 9, batch: 250, retries: 10, retentionDays: 14, startup: 40 },
  edge:   { accept: 3, batch: 25,  retries: 3,  retentionDays: 4,  startup: 6 },
};
export const PROCESSING = 12, PROPAGATION = 480, STORE_READ = 5;   // processing / edge propagation / store response, ms
export const roundTrip = (path, u) => 2 * u[path] + QUEUE[path].accept;
export const row = (...s) => console.log(s.map((x, i) => String(x).padEnd(i ? 13 : 15)).join("").trimEnd());

export function rng(seed) {                          // seed is visible; same sequence run to run
  let s = seed >>> 0;
  return () => (s = (s * 1664525 + 1013904223) >>> 0) / 4294967296;
}
export function jobSet(seed) {                        // reading: counter id, increasing sequence, required retries
  const r = rng(seed), seq = new Map(), items = [];
  for (const u of EDGES) for (let i = 0; i < u.reads; i++) {
    const counterId = `s${String(Math.floor(r() * 900)).padStart(3, "0")}`;
    seq.set(counterId, (seq.get(counterId) ?? 0) + 1);
    items.push({ site: u.name, counterId, seq: seq.get(counterId), retries: 1 });
  }
  for (const i of items) while (r() < 0.28 && i.retries < 12) i.retries++;
  return items;
}
```

```js
// trip.mjs — the same job set in two places: queue-entry trip, where the job runs, the result's return path.
import { EDGES, TOTAL, QUEUE, PROCESSING, PROPAGATION, STORE_READ, roundTrip, row } from "./network.mjs";

row("edge", "reads", "center trip", "edge trip", "gain");
for (const u of EDGES) row(u.name, u.reads, `${roundTrip("center", u)} ms`, `${roundTrip("edge", u)} ms`,
  `${(roundTrip("center", u) / roundTrip("edge", u)).toFixed(1)}x`);
const weighted = (path) => EDGES.reduce((t, u) => t + u.reads * roundTrip(path, u), 0) / TOTAL;
console.log(`weighted queue-entry trip for ${TOTAL} reads: ` +
  `center ${weighted("center").toFixed(1)} ms, edge ${weighted("edge").toFixed(1)} ms\n`);

for (const path of ["center", "edge"]) {            // consumer: how many times it starts
  const k = QUEUE[path], calls = Math.ceil(TOTAL / k.batch);
  const startup = calls * k.startup, work = TOTAL * PROCESSING;
  console.log(`${path.padEnd(7)}batch read ${String(k.batch).padStart(3)} -> ${String(calls).padStart(3)} consumer calls; ` +
    `startup ${String(startup).padStart(4)} ms + processing ${work} ms = ${startup + work} ms`);
}
console.log(`where the job runs: on the center path, a single known region; ` +
  `on the edge path, a location the platform chooses, and the choice is not recorded\n`);

// return path: the center store is remote, the edge store is local but must propagate first
const returnTrip = (path) => EDGES.reduce((t, u) => t + u.reads * (2 * u[path] + STORE_READ), 0) / TOTAL;
console.log(`weighted return trip: center ${returnTrip("center").toFixed(1)} ms, edge ${returnTrip("edge").toFixed(1)} ms`);
console.log(`until the result is ready: center ${returnTrip("center").toFixed(1)} ms, ` +
  `edge ${(returnTrip("edge") + PROPAGATION).toFixed(1)} ms (propagation ${PROPAGATION} ms added)`);
console.log(`end-to-end total: center ${(weighted("center") + returnTrip("center")).toFixed(1)} ms, ` +
  `edge ${(weighted("edge") + returnTrip("edge") + PROPAGATION).toFixed(1)} ms`);
```

```
edge           reads        center trip  edge trip    gain
edge-west      610          33 ms        13 ms        2.5x
edge-north     520          85 ms        17 ms        5.0x
edge-east      430          157 ms       21 ms        7.5x
edge-south     350          111 ms       15 ms        7.4x
edge-highland  280          201 ms       25 ms        8.0x
edge-port      210          135 ms       19 ms        7.1x
weighted queue-entry trip for 2400 reads: center 106.4 ms, edge 17.5 ms

center batch read 250 ->  10 consumer calls; startup  400 ms + processing 28800 ms = 29200 ms
edge   batch read  25 ->  96 consumer calls; startup  576 ms + processing 28800 ms = 29376 ms
where the job runs: on the center path, a single known region; on the edge path, a location the platform chooses, and the choice is not recorded

weighted return trip: center 102.4 ms, edge 19.5 ms
until the result is ready: center 102.4 ms, edge 499.5 ms (propagation 480 ms added)
end-to-end total: center 208.8 ms, edge 517.0 ms
```

On the first measure, the edge wins by a wide margin: the weighted queue-entry trip drops from
106.4 ms to 17.5 ms, and at the farthest edge the gain reaches eightfold.

On the second measure, the table turns around. The edge consumer starts in six milliseconds, the
center in forty; but because the edge's batch size is twenty-five, 2,400 messages split into
ninety-six calls, against ten in the center. **A startup that is cheaper per call ends up costlier
in total because it starts nine times as often.** Where the job runs also becomes uncertain: a
single known region in the center, an unrecorded location the platform chooses at the edge.

The third measure is the return path. The edge's return trip is short at 19.5 ms, but because the
result is written to the edge store, becoming visible at the user's edge waits on the 480 ms
propagation: the end-to-end total comes to 208.8 ms in the center, 517.0 ms at the edge. **The 89
ms gained on queue entry does not make up for the 397 ms lost in making the result readable.**

## The Edge Queue's Four Constraints

```js
// constraint.mjs — the edge queue's four constraints counted on the same job set: retries, retention, ordering, batch.
import { TOTAL, QUEUE, jobSet, rng } from "./network.mjs";

const row = (...s) => console.log(s.map((x, i) => String(x).padEnd(i ? 15 : 17)).join("").trimEnd());
const items = jobSet(20260731);                     // seed is visible
row("constraint", "center", "edge", "measure", "edge's loss");

const deadLettered = (k) => items.filter((i) => i.retries > k.retries).length;   // messages that exceed the retry budget
row("retries", QUEUE.center.retries, QUEUE.edge.retries,
  `${TOTAL} messages`, `${deadLettered(QUEUE.edge)} dead letters (${(100 * deadLettered(QUEUE.edge) / TOTAL).toFixed(1)}%)`);

const OUTAGE_DAYS = 6, DAILY = TOTAL;               // consumer stopped for six days
const retained = (k) => Math.min(OUTAGE_DAYS, k.retentionDays) * DAILY;
row("retention", `${QUEUE.center.retentionDays} d`, `${QUEUE.edge.retentionDays} d`,
  `${OUTAGE_DAYS} d outage`, `${OUTAGE_DAYS * DAILY - retained(QUEUE.edge)} messages dropped`);

const r = rng(770419), shuffled = items.map((i) => [r(), i]).sort((a, b) => a[0] - b[0]).map((x) => x[1]);
const apply = (stream) => { const final = new Map(); for (const i of stream) final.set(i.counterId, i.seq); return final; };
const correct = apply([...items].sort((a, b) => a.seq - b.seq)), actual = apply(shuffled);
const wrong = [...correct].filter(([s, n]) => actual.get(s) !== n).length;
row("ordering", "per key", "none", `${correct.size} counters`,
  `${wrong} counters with wrong index (${(100 * wrong / correct.size).toFixed(1)}%)`);

const calls = (k) => Math.ceil(TOTAL / k.batch);
row("batch read", QUEUE.center.batch, QUEUE.edge.batch, `${TOTAL} messages`,
  `${calls(QUEUE.edge) - calls(QUEUE.center)} extra calls`);

const multiRead = [...correct.values()].filter((n) => n > 1).length, edgeQ = QUEUE.edge;
console.log("\nthe cost of working around it, item by item:");
for (const s of [
  `retries: ${deadLettered(edgeQ)} dead letters requeued by hand -> ${deadLettered(edgeQ)} extra writes; the retention clock resets, sequence starts over`,
  `retention: a second durable copy for ${OUTAGE_DAYS * DAILY - retained(edgeQ)} messages; all ${OUTAGE_DAYS * DAILY} messages get written to two places`,
  `ordering: a buffer for ${multiRead} multi-read counters; ${TOTAL} extra state writes, a 3000 ms wait per message`,
  `batch read: ${calls(edgeQ)} x ${edgeQ.startup} ms = ${calls(edgeQ) * edgeQ.startup} ms, more than the center's ${calls(QUEUE.center) * QUEUE.center.startup} ms`,
]) console.log("  " + s);
```

```
constraint       center         edge           measure        edge's loss
retries          10             3              2400 messages  50 dead letters (2.1%)
retention        14 d           4 d            6 d outage     4800 messages dropped
ordering         per key        none           842 counters   438 counters with wrong index (52.0%)
batch read       250            25             2400 messages  86 extra calls

the cost of working around it, item by item:
  retries: 50 dead letters requeued by hand -> 50 extra writes; the retention clock resets, sequence starts over
  retention: a second durable copy for 4800 messages; all 14400 messages get written to two places
  ordering: a buffer for 667 multi-read counters; 2400 extra state writes, a 3000 ms wait per message
  batch read: 96 x 6 ms = 576 ms, more than the center's 400 ms
```

The four constraints yield four separate numbers. When the retry allowance drops from ten to
three, 50 messages fall into the dead letter queue under the same error distribution; on the
center path, all of them get through. When retention drops from fourteen days to four, of the
14,400 messages that pile up during a six-day outage, **4,800 fall out of the queue**; the center
queue loses none.

The most expensive item is ordering. Without the guarantee, **438 of 842** counters end up with
the wrong index: the last read applied may not be the highest sequence number, and an invoice
comes out short or over. This is not an item a setting can fix — it is a guarantee the queue never
offered in the first place.

The cost of working around it sits in the last block. Requeuing dead letters by hand means 50
extra writes, but the requeued message is new: its retention clock resets and its sequence number
starts over. Longer retention can only be had with a second durable copy, and ordering only with a
buffer kept in the name of 667 counters: all 14,400 messages get written to two places, producing
2,400 extra state writes and a three-second wait per message. **All three are larger than the 89
ms gained at the edge.**

## Which Region a Scheduled Job Triggers In

```js
// schedule.mjs — the scheduled job: which region it fires in, and who owns the time-zone conversion.
import { EDGES, STORE_READ } from "./network.mjs";

const BASE = 2, DRIFT_DAYS = 217, DRIFTING_BANDS = [2, 3];   // base clock 02:00, seasonal drift window
const hour = (h) => `${String(((h % 24) + 24) % 24).padStart(2, "0")}:00`;
const dayNote = (h) => (h < 0 ? " (previous day)" : "");

const row = (...s) => console.log(s.map((x, i) => String(x).padEnd(i ? 16 : 15)).join("").trimEnd());
row("edge", "band", "single trigger", "local time", "drift window");
for (const u of EDGES) row(u.name, `+${u.band}`, hour(BASE), hour(BASE + u.band),
  DRIFTING_BANDS.includes(u.band) ? `${DRIFT_DAYS} d 60 min` : "none");

const localHours = new Set(EDGES.map((u) => BASE + u.band));
const bands = [...new Set(EDGES.map((u) => u.band))].sort();
console.log(`\nsingle trigger -> ${localHours.size} distinct local times; ` +
  `${EDGES.filter((u) => u.band === 0).length}/${EDGES.length} edges see the requested ${hour(BASE)}`);
console.log(`every edge needs ${bands.length} separate trigger definitions for ${hour(BASE)}: ` +
  bands.map((s) => `+${s} base ${hour(BASE - s)}${dayNote(BASE - s)}`).join(", "));
const drifting = EDGES.filter((u) => DRIFTING_BANDS.includes(u.band));
console.log(`seasonal drift: ${drifting.length} edges x ${DRIFT_DAYS} days = ${drifting.length * DRIFT_DAYS} edge-days ` +
  `of 60 min deviation; the scheduler knows only the base clock, the function does the conversion itself\n`);

const READS = 2400, PACKET = 100, rounds = Math.ceil(READS / PACKET);
const duration = (u) => rounds * (2 * u.center + STORE_READ);
const l = EDGES.map(duration), min = Math.min(...l), max = Math.max(...l);
console.log(`nightly job: ${rounds} rounds x ${PACKET} reads; because the trigger location cannot be chosen, duration ` +
  `falls between ${min}-${max} ms, average ${(l.reduce((a, b) => a + b) / l.length).toFixed(0)} ms ` +
  `(${(max / min).toFixed(1)}x spread)`);
console.log(`the cost of pinning: if the job is pinned to the center region, duration is fixed at ${min} ms, ` +
  `but the ability to run at the edge is given up entirely for this job`);
```

```
edge           band            single trigger  local time      drift window
edge-west      +0              02:00           02:00           none
edge-north     +0              02:00           02:00           none
edge-east      +2              02:00           04:00           217 d 60 min
edge-south     +1              02:00           03:00           none
edge-highland  +3              02:00           05:00           217 d 60 min
edge-port      +2              02:00           04:00           217 d 60 min

single trigger -> 4 distinct local times; 2/6 edges see the requested 02:00
every edge needs 4 separate trigger definitions for 02:00: +0 base 02:00, +1 base 01:00, +2 base 00:00, +3 base 23:00 (previous day)
seasonal drift: 3 edges x 217 days = 651 edge-days of 60 min deviation; the scheduler knows only the base clock, the function does the conversion itself

nightly job: 24 rounds x 100 reads; because the trigger location cannot be chosen, duration falls between 696-4728 ms, average 2792 ms (6.8x spread)
the cost of pinning: if the job is pinned to the center region, duration is fixed at 696 ms, but the ability to run at the edge is given up entirely for this job
```

The nightly job's request to "run at 02:00 at every branch" cannot be satisfied by a single
trigger definition. A trigger set on the base clock lands on four distinct local times, and only
two of the six edges see the requested hour. Every edge needs four definitions for 02:00, and
band +3's falls on base 23:00 — that is, **the previous day**. Two bands also drift sixty minutes
on 217 days of the year: 651 edge-days of deviation. **The scheduler knows only the base clock;
the decision to convert to local time is not handed over, it stays in the function's code.**

The last two lines count where the trigger actually runs. The nightly job reads the center store
in twenty-four rounds; because where it runs cannot be chosen, the same job finishes anywhere
between 696 and 4,728 ms, averaging 2,792 ms — **a 6.8-fold range, and which edge it lands on is
not our decision.** The only way to fix the duration is to pin the job to the center; then it does
not run at the edge.

## Summary

- The queue-entry trip fell at the edge from 106.4 ms to 17.5 ms, and the gain at the farthest
  edge reached 8-fold; but because the batch read dropped from 250 to 25, 2,400 messages split
  into 96 calls instead of 10, and total startup came to 576 ms instead of 400.
- Because the result was written to the edge store, it waited on a 480 ms propagation; the
  end-to-end total came to 208.8 ms in the center, 517.0 ms at the edge. Acceptance sped up,
  delivery slowed down.
- The four constraints yielded four numbers: a 3-retry allowance produced 50 dead letters, 4-day
  retention lost 4,800 messages in a 6-day outage, the absence of an ordering guarantee left 438
  of 842 counters with the wrong index, and a batch of 25 cost 86 extra calls. Working around them
  cost 50 extra writes, writing 14,400 messages to two places, 2,400 extra state writes, and a
  3,000 ms wait.
- A single trigger fell across 4 distinct local times, and 2 of 6 edges saw the requested hour;
  seasonal drift came to 651 edge-days, and the nightly job took 696-4,728 ms depending on where
  it was triggered.

## Next Step

The edge runtime reached a queue and a scheduler, but how that access gets set up was never
asked. A resource's address and credentials can be handed over by building a client at runtime,
or by a **declarative binding** at deploy time. The next lesson reaches the same resource both
ways and counts four things: lines of code, startup work, where the credential sits, and the
moment a misconfiguration is noticed.
