Here's the moment. An agent run finishes, and now three things have to happen: fire an outbound webhook, invalidate a cache, and enqueue a follow-up task. Three independent workers, one event. That's fan-out — and the instinct, drilled in by a decade of "just use Kafka" blog posts, is to stand up a broker.
For a solo builder, that instinct is almost always wrong. Kafka is excellent at what it does, but what it does is high-throughput, replayable, multi-team event streaming, and it charges you in operational overhead — partitions, rebalancing, retention tuning — that you pay every day whether or not your volume ever justifies it. Below that line, you already have everything you need. The only question is which of the three tools already in your stack fits, and the answer turns on a single axis: durability.
First, the distinction that picks the tool#
Fan-out means one event reaches every interested consumer — each gets its own copy. That is not the same as a work queue, where many workers compete so each message is handled by exactly one of them.
Keep those apart, because it's exactly where the naive SQS answer breaks: three workers reading one SQS queue don't each get the event — they split the events between them. That's load balancing, not fan-out. Hold onto that; it's the crux below.
Postgres LISTEN/NOTIFY — the nudge you already own#
If you already run Postgres, you already have a pub/sub bus. A backend runs LISTEN new_run; anything that calls NOTIFY new_run, '...' (or pg_notify) makes every connected listener receive the payload once the transaction commits. That's true broadcast fan-out, built in, zero new infrastructure.
The catch is one word: fire-and-forget. If no backend is LISTENing at the instant of commit, that notification is gone — there is no replay, no backlog. And the payload caps at 8000 bytes.
So the production pattern is not to ship the job inside the notification. You put the work in a table and use NOTIFY as a cheap wakeup:
-- producer, inside the same transaction that creates the work
INSERT INTO jobs (kind, payload) VALUES ('webhook', $1);
NOTIFY jobs; -- just a "wake up", not the data
-- each worker, on wakeup OR on a slow poll fallback
SELECT id, payload FROM jobs
WHERE status = 'queued'
FOR UPDATE SKIP LOCKED
LIMIT 1;
FOR UPDATE SKIP LOCKED is the durable half: multiple workers can pull without stepping on each other, and the row is the source of truth. Now a missed NOTIFY costs you nothing — the row is still there and gets picked up on the next poll. The notification just makes the common case instant instead of poll-latency slow.
Choose it when: you already run Postgres, your volume is modest, and a missed nudge degrading to a one-second poll is fine. It's the least code and the least to operate. (If you're already leaning on Postgres for agent state, note the same durability-vs-latency tradeoff shows up in Postgres vs Redis as a LangGraph checkpointer.)
Redis Streams — durable fan-out on one box#
When you need every consumer to get every event and you can't lose a message when a worker is down, Redis Streams are the middle path — durable fan-out without standing up Kafka.
XADD appends an event to a stream. The fan-out comes from consumer groups: each group created with XGROUP keeps its own cursor over the full stream, so N groups is N independent lanes all seeing every message.
XADD runs * run_id 42 status done # producer appends
XGROUP CREATE runs webhookers $ MKSTREAM # one lane
XGROUP CREATE runs cachebusters $ # another lane, same events
XREADGROUP GROUP webhookers w1 COUNT 1 STREAMS runs > # deliver
XACK runs webhookers 1690000000000-0 # mark handled
The durability and at-least-once story is the pending-entries list: a message delivered but not XACK'd stays pending, and XPENDING + XAUTOCLAIM let a healthy worker find and take over messages a crashed consumer never acknowledged — the same stuck-message recovery a dead-letter queue gives you. Cap growth with MAXLEN on XADD so the stream doesn't eat all your memory.
The tradeoff: durability is only as strong as your Redis persistence (AOF/RDB), and you're the one operating Redis. Choose it when: you want durable multi-consumer fan-out, you're comfortable running Redis, and one box is enough. (For where Streams sit against Kafka and NATS at higher volume, see Kafka vs NATS vs Redis Streams for AI agents.)
SNS-to-SQS — zero servers, and the trap in the single queue#
SQS is the no-ops option: fully managed, durable, messages retained up to 14 days, a native dead-letter queue via maxReceiveCount. But here's the trap from the top of the piece — a single SQS queue is competing-consumers, not fan-out. Point three workers at one queue and each message goes to one of them.
The fix is to put SNS (or EventBridge) in front and subscribe one SQS queue per consumer:
┌──▶ SQS: webhooks ──▶ webhook workers
SNS topic ───────┼──▶ SQS: cache-bust ──▶ cache workers
(run.finished) └──▶ SQS: follow-ups ──▶ follow-up workers
SNS copies each published message into every subscribed queue; each queue then has its own competing-consumers pool, its own visibility timeout, and its own DLQ. You get managed durability and native dead-lettering with nothing to run — at the cost of one more AWS moving part and per-message pricing.
Choose it when: you want zero servers, you're already on AWS, and a managed DLQ matters more than the extra indirection.
The decision, compressed#
- Already run Postgres, modest volume, a missed nudge is survivable →
LISTEN/NOTIFYover aSKIP LOCKEDtable. Least to build, least to operate. - Need durable fan-out to several consumers on infra you run → Redis Streams with consumer groups and
XAUTOCLAIM. - Want no servers and AWS-native durability with a managed DLQ → SNS-to-SQS, one queue per consumer.
- Kafka → when your throughput and team size make operating it cheaper than not having it. That threshold is real, and it is almost certainly not where a solo founder is today.
The mistake isn't picking the "wrong" one of these three — all three ship real products. The mistake is skipping past all three to Kafka because a blog post from a company with fifty engineers told you to. Fan-out for an agent that finished a run is a small problem. Solve it with a small tool.



