Fan-out & Delay¶
Sometimes one stream of events needs to feed many independent consumers — analytics wants everything, billing wants everything, and a retry system wants everything an hour later. Fan-out does this without producers changing anything.
The idea¶
You attach child topics to a parent topic. From that moment, every message committed to the parent is automatically copied to every child. Each child is a completely normal topic with its own consumers, retention, and pace.
flowchart LR
P[producer] --> parent[(orders)]
parent -->|copy| c1[(orders-analytics)]
parent -->|copy| c2[(orders-billing)]
parent -->|"copy, +1 hour"| c3[(orders-retry)]
parent --> pc[direct consumers]
c1 --> a[analytics]
c2 --> b[billing]
c3 --> r[retry worker]
Attaching a child¶
Both topics must already exist. Then:
curl -u $AUTH -X POST $NARAD/v1/topics/orders/children \
-H "Content-Type: application/json" \
-d '{"child": "orders-analytics"}'
Rules to know:
- Fan-out starts from the moment of attach. Messages already in the parent are not backfilled.
- Copies preserve the message key, so related messages stay grouped in each child.
- A child belongs to one parent; a parent can have up to 108 children; chains (child of a child) are not allowed.
- If the parent enforces a schema, children must be schema-compatible — attach fails with
409otherwise. - Detach and re-attach = a fresh start from the parent's tail, never a resume or replay.
curl -u $AUTH $NARAD/v1/topics/orders/children # list children + how far behind each is
curl -u $AUTH -X DELETE $NARAD/v1/topics/orders/children/orders-analytics # detach
Creating a child in one call¶
You can also create-and-attach in a single request — pass parent when
creating the topic:
curl -u $AUTH -X POST $NARAD/v1/topics -H "Content-Type: application/json" -d '{"name": "orders-analytics", "parent": "orders"}'
Same rules as attach (the parent must exist, you need manage rights on
it, fanout_delay_ms makes it a delay child), plus two conveniences:
leave partitions at 0 and the child inherits the parent's count,
and if the attach can't complete the create is rolled back — no
half-linked topic left behind.
One-call creation is not just sugar. It's the only way a child's partitions can be placed away from the parent's — which is the whole replica pattern below. A topic created standalone gets its partitions assigned immediately, before any attach can happen, and assignments never move.
Replication, when you ask for it¶
Narad famously keeps one copy of each partition. But a fan-out child IS a full second copy of the parent's data — durably committed before the fan-out cursor advances — and if its partitions live on different nodes than the parent's, that copy survives the parent's disk.
That placement is exactly what create-with-parent guarantees:
A child created with
parent, keeping the inherited partition count, on a cluster with at least 2 live nodes: every keyed record's parent copy and child copy live on different nodes. Partition p of the child is deliberately assigned away from the owner of the parent's partition p.
# One line of replication:
curl -u $AUTH -X POST $NARAD/v1/topics -d '{"name": "orders-replica", "parent": "orders"}'
Verify it yourself — partition stats carry owner_node:
curl -u $AUTH $NARAD/v1/topics/orders | jq '.partitions[] | {index, owner_node}'
curl -u $AUTH $NARAD/v1/topics/orders-replica | jq '.partitions[] | {index, owner_node}'
Honest fine print, because a pattern is not a subsystem:
- It's asynchronous. The copy trails the parent by the fan-out lag (typically sub-second). Records committed to the parent but not yet fanned out die with the parent's volume. RPO ≈ seconds, not zero.
- No automatic failover. If the parent's node is lost, consumers
switch to
orders-replicathemselves — it's a normal topic. This is disaster recovery, not transparent HA. - From attach onward. No backfill; the replica covers what the parent committed after it was created.
- It costs what fan-out costs: double the disk and double the write IO, only on the topics that opt in.
- The replica can have longer retention than the parent — a cheap archive tier is the same one line.
- Children attached the two-step way (create, then attach) keep the placement they got at creation, which is not anti-affine. For the replica pattern, use one-call creation.
Delay children¶
Add delay_ms and the child becomes a delay topic: each message appears in it exactly that long after the parent committed it.
curl -u $AUTH -X POST $NARAD/v1/topics/orders/children \
-H "Content-Type: application/json" \
-d '{"child": "orders-retry", "delay_ms": 3600000}'
sequenceDiagram
participant P as Producer
participant O as orders
participant R as orders-retry (1h delay)
participant W as Retry worker
P->>O: produce at 12:00
Note over R: message exists but is withheld
O->>R: delivered at 13:00
R->>W: consumable from 13:00
The fine print:
- Delay is measured from the parent commit time on the server — your producer's clock doesn't matter.
- Delay is fixed per child (up to 1 year) and immutable after attach. Want a different delay? Detach and attach a new child.
- You cannot produce directly to a delay child (
409) — its whole timeline comes from the parent, which is what makes the delay trustworthy. - The parent's retention must be at least
delay + 1 hour, and Narad enforces this at attach time and blocks retention changes that would violate it. This guarantees a message can never age out of the parent before its delayed delivery. - Delivery is "no earlier than" the delay, typically within a second after. Under failures it can be later — never earlier.
What fan-out costs you¶
Every child stores its own full copy of the data (that's what makes children independent). Ten children = ten copies of every parent message on disk. Budget retention accordingly.