---
title: 'Queue and Topic Services'
source: 'https://academia.sh/en/courses/serverless/queue-and-topic-services'
course: 'Serverless and Edge Computing'
language: en
updated: '2026-09-13T05:07:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts the decisions handed over to the queue service and measures the configuration surface that remains'
    - 'Scans visibility timeout to measure the trade-off between double processing and recovery delay'
    - 'Compares the fan-out difference between queue and topic services in terms of delivery count and configuration items'
---

# Queue and Topic Services

The constraints that come from taking the queue as a service: counting the five remaining configuration items, scanning visibility timeout across six values to measure the double processing a short duration produces against the recovery delay a long one produces, the dead-letter drop time turning out to be a derived number, and comparing the fan-out difference between queue and topic services in terms of delivery and configuration items.

The previous lesson routed events to targets by rule and counted every delivery as successful.
That assumption does not hold in a real stream: the target may be throttled, may error, may not
be standing at all. The buffer in between is built by the **queue service** — it holds the event,
keeps it waiting until the consumer is ready, and makes failed work visible again.

The queue's mechanics are not new. The Delivery Semantics lesson of the Asynchronous Processing
course measured the order of acknowledgment against effect and showed the difference between
at-least-once and deduplication with numbers; that measurement is not repeated here and its
results are taken as given. This lesson's question is different: what changes when the queue is
not a tool you run yourself but **a service**?

## Handed-Over Decisions and the Surface Left Behind

In a queue taken as a service, the following decisions leave your hands: how many nodes run, how
many copies a message is stored with, where the disk writes, when the version is upgraded, how
many consumers get added when queue depth grows. The last one matters most; once the queue
service is wired to a function-execution environment, the scaling decision is handed over too —
instances are added as depth rises, removed as it falls.

The cost of the handed-over scaling decision is that the **speed** of the increase leaves your
hands too. When 400 messages land in the queue at once, the rule that decides how many instances
get added and how fast sits inside the service; the only thing visible from outside is a ceiling
number. This produces a two-way constraint. If the ramp-up is slow, the nightly batch job finishes
later than expected and queue depth stays high for hours. If the ramp-up is fast, the hundreds of
instances that wake at once drain the connection pool of the database behind it — the queue finds
its own scale, but the scale it finds exceeds a downstream constraint. A consumer count you set
yourself, watching queue depth, would solve both; with the handed-over decision, that lever is
gone too. The only control left is lowering the ceiling itself, and the cost of that is making the
first case permanent.

The remaining surface is small and countable. In the model used in this lesson there are five
items per queue: **visibility timeout**, **maximum delivery count**, **dead letter queue**,
**retention period**, **batch size**. Five items are the entire control left in exchange for all
those handed-over decisions. The constraint is here: what cannot be tuned cannot be fixed either.
A message whose retention period expires is deleted, and that deletion, like the unmatched event
in the previous lesson, is silent.

The measurement again runs on the fictional **regional measurement network**: meter readings are
written to a queue, the validation function reads from the queue, and the validated reading enters
the invoicing stream.

- **ED7.** The workload is 400 messages; processing time falls into three clusters — ordinary
  validation 2–6 units, batch read 12–31 units, delayed batch 40–69 units.
- **ED8.** 12 of the messages are poison (they fail on every attempt), 16 have a consumer that
  dies before finishing the work; the two clusters never overlap and are fixed by position.
- **ED9.** Maximum delivery count is 5; a message that reaches this count moves to the dead
  letter queue.
- **ED10.** When visibility timeout expires the message becomes visible again; a consumer still
  running is not stopped, meaning a second consumer redoes the work **concurrently**.
- **ED11.** The duration unit is fictional; what is measured is ratios, not seconds.
- **ED12.** The model builds only a single queue; ordering, partitioning, and transport latency
  are not measured.

## Visibility Timeout

**Visibility timeout** is how long a message stays hidden in the queue after a consumer picks it
up. If the consumer finishes the work and deletes the message within that time, the job is done;
if it does not finish, the message becomes visible again and another consumer picks it up. It is a
single number that decides two separate things at once: when slow work gets started a second
time, and when a dead consumer's work gets recovered. These two requirements pull in opposite
directions; the scan below measures both against the same workload.

```js
// visibility.mjs — queue service model: what happens when visibility timeout changes
// Model: not a real queue, but the counted results of the service's one setting.
const SEED = 20260518;                        // generator seed: run is reproducible
let state = SEED;
const random = () => (state = (state * 1103515245 + 12345) % 2147483648) / 2147483648;

const N = 400, MAX_DELIVERIES = 5;             // after MAX_DELIVERIES the message goes to the dead letter queue
const MESSAGES = [];
for (let i = 0; i < N; i++) {
  const p = random();
  const duration = p < 0.88 ? 2 + Math.floor(random() * 5)        // ordinary validation: 2-6 units
             : p < 0.97 ? 12 + Math.floor(random() * 20)          // batch read: 12-31 units
             : 40 + Math.floor(random() * 30);                    // delayed batch: 40-69 units
  MESSAGES.push({ duration, poison: i % 33 === 7, crashed: i % 25 === 3 });   // the two never overlap
}
const POISON = MESSAGES.filter((m) => m.poison).length;
const CRASHED = MESSAGES.filter((m) => m.crashed).length;

function run(T) {
  let delivered = 0, doubled = 0, wait = 0;
  for (const m of MESSAGES) {
    if (m.poison) { delivered += MAX_DELIVERIES; continue; }        // fails again on every retry
    if (m.crashed) { delivered += 2; wait += T; continue; }          // consumer died: stays hidden for T
    const tries = Math.ceil(m.duration / T);                        // work not finished before T reappears
    delivered += tries;
    if (tries > 1) doubled++;
  }
  return { delivered, doubled, wait: wait / CRASHED, deadLetter: MAX_DELIVERIES * T };
}

console.log("visibility".padEnd(12) + "double processing".padStart(20) + "total delivered".padStart(18) +
  "recovery delay".padStart(20) + "dead-letter drop".padStart(19));
for (const T of [5, 10, 20, 30, 60, 120]) {
  const s = run(T);
  console.log(String(T).padEnd(12) + String(s.doubled).padStart(20) + String(s.delivered).padStart(18) +
    s.wait.toFixed(0).padStart(20) + String(s.deadLetter).padStart(19));
}
const LONGEST = Math.max(...MESSAGES.map((m) => m.duration));
console.log(`messages: ${N}, poison: ${POISON}, consumer crashed: ${CRASHED}, ` +
  `longest job: ${LONGEST} units, seed: ${SEED}`);
```

```sh
node visibility.mjs
```

```
visibility     double processing   total delivered      recovery delay   dead-letter drop
5                            102               745                   5                 25
10                            40               562                  10                 50
20                            28               505                  20                100
30                            11               480                  30                150
60                             5               469                  60                300
120                            0               464                 120                600
messages: 400, poison: 12, consumer crashed: 16, longest job: 69 units, seed: 20260518
```
The first row shows the cost of a short timeout. With visibility timeout at 5, **102 messages
were double-processed** and a workload of 400 messages turned into 745 deliveries. Double
processing here is a different thing from a retry: while the first consumer is still working, the
message becomes visible again and a second consumer does the same job **at the same time**. In
the measurement network this means the same meter reading gets validated twice and enters the
invoicing stream twice.

The last row is the cost of a long timeout. At 120 units, double processing falls to zero, but the
16 messages whose consumer died wait an average of **120 units hidden** — invisible for that whole
span, unreachable by any consumer, showing up in no metric. A poison message's drop into the dead
letter queue, meanwhile, takes 600 units.

The practical consequence of this trade-off is that concurrent double processing also stresses the
deduplication guard. The guard asks "has this message already been processed"; in a concurrent
run, the second consumer asks that question before the first has written its result, and the
answer comes back "no." A guard that works fine under sequential retries fails both runs here. What
closes double processing is not the guard's existence but whether the guard relies on a uniqueness
constraint at write time.

The rule sits in the output's last line: **the longest job is 69 units.** For double processing to
reach zero, visibility timeout has to exceed the longest job; at 60 units, 5 messages are still
double-processed, at 120 it is zero. In other words, the queue of processing durations sets the
lower bound of recovery delay. A single long-running job type drags down the recovery behavior of
the whole queue.

The way around the constraint is to extend visibility timeout while the work is running: the
consumer reports at regular intervals that it is still alive, and the service moves the counter
forward. This costs two things. First, finishing a 69-unit job with a 10-unit timeout takes six
extension calls — a single message's work turns into seven requests, and in a service billed per
request this is a direct charge. Second, the consumer is no longer only doing its job; it also has
to manage its own lifetime, and a consumer that forgets to extend, or whose extension call fails,
falls right back into the first row's double processing.

## The Automatic Nature of the Dead Letter Queue

The table's last column is a number no one sets directly. The time it takes a poison message to
reach the dead letter queue is the product of maximum delivery count and visibility timeout:
5 × 30 = 150, 5 × 120 = 600. Two separate items chosen for separate reasons, and a third number
emerges on its own. This is the sneakiest form of constraint — what is tuned is not what is
measured.

The gain of being a service here is real: a poison message is not weeded out by hand; after the
fifth delivery it is moved automatically and does not clog the main queue. In the model, 12 poison
messages, whatever visibility timeout is chosen, spend a total of 60 deliveries and get out of the
way. What comes back in exchange is this: **the dead letter queue has no consumer by default.** A
message moves there, sits there, and is deleted once retention expires. If no one reads it, the
result is the silent drop from the previous lesson wearing a receipt — the loss is the same loss,
only written down somewhere.

The way to empty the queue is to push the messages back onto the main queue, and this too has a
number. If the 12 messages are pushed back without the cause being fixed, they get delivered five
more times each; 60 more deliveries are spent and the messages return to the same queue. Pushing
back is therefore not a repair but a test: first the poisoning is found, then the queue is
emptied.

Retention is an upper bound in the same way. If the consumer side has an outage longer than
retention, messages in the queue start getting deleted one by one, and once the outage ends the
queue looks empty. In the measurement network this means the meter readings that landed during
the outage window are never billed at all. The way around the constraint is to push retention to
its upper limit; the cost is that the queue starts being used like a backup layer, and if depth is
not being watched, the loss is only noticed at billing time.

## The Difference Between a Queue and a Topic

The second service type delivers the same message to more than one party. The difference sits in
one sentence: a queue **shares out** the work; a topic **duplicates** the message.

```js
// fanout.mjs — same 400 events through two services: queue service and topic service
const N = 400;
const QUEUE_ITEMS = 5;   // visibility timeout, max deliveries, dead letter queue, retention, batch size
const TOPIC_ITEMS = 2;   // the topic's own items: name and access rule

console.log("consumer".padEnd(10) + "queue delivered".padStart(17) + "queue items".padStart(14) +
  "topic delivered".padStart(17) + "topic items".padStart(13));
for (const k of [1, 2, 3, 4]) {
  const topicItems = TOPIC_ITEMS + k * (QUEUE_ITEMS + 1);  // each subscription: its own queue + filter
  console.log(String(k).padEnd(10) + String(N).padStart(17) + String(QUEUE_ITEMS).padStart(14) +
    String(N * k).padStart(17) + String(topicItems).padStart(13));
}
console.log("work per consumer: queue " + N / 4 + ", topic " + N + " (with four consumers)");
```

```sh
node fanout.mjs
```

```
consumer    queue delivered   queue items  topic delivered  topic items
1                       400             5              400            8
2                       400             5              800           14
3                       400             5             1200           20
4                       400             5             1600           26
work per consumer: queue 100, topic 400 (with four consumers)
```
The queue column stays flat: whatever the consumer count, delivered stays at 400, work per
consumer drops to a quarter, and the configuration item count sits fixed at five. Adding a
consumer is a scaling decision and does not change the cost.

The topic column grows linearly. With a fourth subscriber delivered reaches 1,600 and
configuration items reach 26. Adding a subscriber is not a scaling decision, it is opening a new
line of work: each subscriber gets its own copy of the message, its own visibility timeout, its
own maximum delivery count, its own dead letter queue, and its own filter. 26 items are 26 places
that can be misconfigured, and six of them arrive with every new subscriber.

Notice that this growth is the same surface measured in the previous lesson. Adding a subscriber
does not change one line at the producer, does not disturb the numbers of existing subscribers,
and produces no error — the rule's invisibility comes back here as the subscription's invisibility.
The difference is this: in the rule, the cost of an extra delivery was a call; in the topic, the
cost is a call plus the storage of 400 copies.

The two services do not have to be alternatives to each other. A common pattern is to bind each of
a topic's subscriptions to a queue: the topic duplicates, each queue shares out on its own side.
The structure of the model's 26 items is already this — the topic's two items plus five queue
items and a filter per subscriber. In this arrangement the previous lesson's filter measurement
applies directly: if the filter sits in the subscription, an irrelevant event is never written to
the queue at all; if it sits at the consumer, it is written, stored, delivered, and then discarded.
The difference this time shows up in three items at once — delivery count, stored message count,
and the consumer's wasted wake-ups.

The choice criterion is the nature of the work. A queue is for consumers who will share the same
job; a topic is for parties who will each derive a different piece of work from the same event. In
the measurement network, a validated reading feeds into a single job — it becomes a billing item —
and that job is shared out: a queue. If the same reading is meant to produce a bill, an archive
entry, and a consumption curve, there are three separate jobs: a topic.

## Summary

- Taking the queue as a service hands over decisions about node count, copy count, version
  upgrades, and consumer scaling; five tunable items remain.
- At a visibility timeout of 5 units, the 400-message workload turned into 745 deliveries and 102
  messages were double-processed concurrently; at 120 units double processing hit zero, but the 16
  messages whose consumer died waited an average of 120 units hidden.
- Because the longest job was 69 units, double processing only reached zero at 120 units:
  processing time sets the lower bound of recovery delay.
- The time it takes to reach the dead letter queue is not a tunable item but the product of
  maximum deliveries and visibility timeout: 5 × 120 = 600 units. The queue itself has no consumer
  by default.
- Queue delivery stayed at 400 regardless of consumer count and configuration stayed at five
  items; with four subscribers a topic's delivery reached 1,600 and configuration items reached
  26. A queue shares out the work, a topic duplicates the message.
- Extending visibility timeout while work runs closes double processing, but finishing a 69-unit
  job with a 10-unit timeout takes six extension calls: a single message's work turns into seven
  requests, and the consumer has to manage its own lifetime.

## Next Step

The queue safely links two functions: one writes, the other reads, and if something fails in
between the message comes back. But invoice generation in the measurement network is not two
steps; a reading is validated, consumption is computed, a tariff is applied, an invoice is
written, a notification is sent. When the fourth of five steps errors, no queue has written down
what happens to the first three — a queue only knows its own message, not the whole flow. The
next lesson measures that gap: a step-based state machine is run, retries and compensation steps
are counted per step, and the question of where the flow's state is held gets asked.
