# Queues without Kafka: SQS, EventBridge, and the one place we actually needed a stream

Every platform reaches the moment where a request does too much. The checkout handler sends an email, updates the search index, notifies the warehouse, records an analytics event and, oh, also charges the card. It takes four seconds and fails if any of the five things is slow. The fix is a queue, and the first proposal is usually Kafka, because Kafka is what the big companies use and the conference talks are about Kafka.

We do not run Kafka. For a platform we operate for a US client, we run SQS for work, EventBridge for events, and one Kinesis stream for the single workload that needed ordering and replay. Here is how we split it, what each costs, and the test for whether you need a stream at all.

## Three different problems

"Queue" is doing a lot of work in most architecture conversations. There are three shapes hiding under it.

**Work to be done, once, by someone.** Send this email. Resize this image. Sync this order to the warehouse. The producer does not care who does it or when, only that it happens, and happens once. This wants a *queue*: an item is delivered to one consumer, acknowledged when done, retried if not, dead-lettered after too many failures.

**Something happened, and anyone interested should know.** An order was placed. A customer changed their email. The producer does not know who is listening and should not have to. This wants an *event bus*: one publish, many subscribers, each with their own queue behind it, added and removed without touching the producer.

**An ordered, replayable history.** Every change to this account, in order, that a consumer can read from any point and re-read after a bug. This wants a *stream*, and it is the only one of the three that Kafka is uniquely good at. It is also the rarest need.

Most platforms have a lot of the first, some of the second, and one or zero of the third. Kafka does all three, at the cost of running Kafka, or paying for a managed Kafka that starts at a few hundred dollars a month before you have sent a message.

<div class="article-figure">
<svg viewBox="0 0 900 260" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three shapes. Queue: work done once by one consumer, retry and dead-letter, SQS, about 2 dollars a month. Event bus: one publish, many subscribers each with its own queue, EventBridge, about 1 dollar. Stream: ordered, replayable, read from any offset, Kinesis, about 15 dollars for one shard. Below: Kafka does all three for a few hundred a month plus an operator.">
<g font-family="Inter,system-ui,sans-serif" font-size="12">
<rect x="20" y="20" width="270" height="180" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="155" y="44" text-anchor="middle" fill="#4fffb0" font-weight="700">queue · work</text><text x="155" y="70" text-anchor="middle" fill="#f1f3ff">do this once, by someone</text><text x="155" y="90" text-anchor="middle" fill="#f1f3ff">one consumer takes it</text><text x="155" y="110" text-anchor="middle" fill="#f1f3ff">ack · retry · dead-letter</text><text x="155" y="140" text-anchor="middle" fill="#9aa3c7" font-size="11">SQS · 11 queues</text><text x="155" y="158" text-anchor="middle" fill="#9aa3c7" font-size="11">emails, image resizes, warehouse sync</text><text x="155" y="184" text-anchor="middle" fill="#4fffb0" font-weight="700">~$2 / month</text>
<rect x="315" y="20" width="270" height="180" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="450" y="44" text-anchor="middle" fill="#7b8cff" font-weight="700">event bus · facts</text><text x="450" y="70" text-anchor="middle" fill="#f1f3ff">this happened</text><text x="450" y="90" text-anchor="middle" fill="#f1f3ff">one publish, many subscribers</text><text x="450" y="110" text-anchor="middle" fill="#f1f3ff">each gets its own queue</text><text x="450" y="140" text-anchor="middle" fill="#9aa3c7" font-size="11">EventBridge · 1 bus · 9 rules</text><text x="450" y="158" text-anchor="middle" fill="#9aa3c7" font-size="11">OrderPlaced, CustomerUpdated, RefundIssued</text><text x="450" y="184" text-anchor="middle" fill="#7b8cff" font-weight="700">~$1 / month</text>
<rect x="610" y="20" width="270" height="180" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="745" y="44" text-anchor="middle" fill="#ffd166" font-weight="700">stream · history</text><text x="745" y="70" text-anchor="middle" fill="#f1f3ff">ordered per key, replayable</text><text x="745" y="90" text-anchor="middle" fill="#f1f3ff">read from any point</text><text x="745" y="110" text-anchor="middle" fill="#f1f3ff">re-read after a bug</text><text x="745" y="140" text-anchor="middle" fill="#9aa3c7" font-size="11">Kinesis · 1 stream · 1 shard</text><text x="745" y="158" text-anchor="middle" fill="#9aa3c7" font-size="11">the ledger projection, and only that</text><text x="745" y="184" text-anchor="middle" fill="#ffd166" font-weight="700">~$15 / month</text>
<text x="450" y="230" text-anchor="middle" fill="#9aa3c7">Kafka does all three. Managed Kafka starts at a few hundred a month; self-hosted starts at an operator.</text>
<text x="450" y="248" text-anchor="middle" fill="#9aa3c7">Most platforms need a lot of the first, some of the second, and one or zero of the third.</text>
</g>
</svg>
</div>

## Queues: SQS

Eleven SQS queues, one per kind of work, each with a dead-letter queue and a Lambda or an App Runner worker consuming it. The checkout handler that took four seconds now writes the order, charges the card (the one thing that must be synchronous), publishes one event, and returns in 400 ms. Everything else is a queue consumer.

The settings that matter, in CDK:

```ts
const dlq = new sqs.Queue(this, 'EmailDlq', { retentionPeriod: Duration.days(14) });
const emailQueue = new sqs.Queue(this, 'EmailQueue', {
  visibilityTimeout: Duration.seconds(90),      // > 6 × the consumer's timeout
  deadLetterQueue: { queue: dlq, maxReceiveCount: 5 },
  encryption: sqs.QueueEncryption.SQS_MANAGED,
});
new lambda.EventSourceMapping(this, 'EmailConsumer', {
  target: emailFn, eventSourceArn: emailQueue.queueArn,
  batchSize: 10, reportBatchItemFailures: true,     // partial batch success
  maxConcurrency: 20,                                // protect the email provider
});
new cloudwatch.Alarm(this, 'EmailDlqAlarm', {
  metric: dlq.metricApproximateNumberOfMessagesVisible(), threshold: 1, evaluationPeriods: 1,
});
```

Three things that were wrong in the first version and are right now. The visibility timeout was equal to the function timeout, so a slow message was redelivered while still being processed, and emails went out twice; it is now six times the function timeout, as the docs say and nobody reads. `reportBatchItemFailures` was off, so one bad message in a batch of ten failed all ten, and nine good emails were retried five times each before the batch hit the dead-letter queue. And the dead-letter queue had no alarm, so messages sat in it for a week before anyone looked; it now pages at one.

**FIFO or standard?** Standard, everywhere except the warehouse sync, which needs "cancel" to arrive after "create" for the same order. FIFO with the order id as the message group gives that, at roughly the same price, with a throughput ceiling per group we are nowhere near. Do not default to FIFO; it adds a deduplication and ordering contract that most work does not want and that makes a stuck message block everything behind it in its group.

## Events: EventBridge

One custom bus. Producers put events with a `detail-type` and a source; rules match and route to targets, which are almost always an SQS queue owned by the consuming service, with a Lambda behind it. The producer of `OrderPlaced` has no idea that five services subscribe to it, and when the sixth appears next quarter, it is a new rule and a new queue, with no change to checkout.

```ts
const bus = new events.EventBus(this, 'PlatformBus');
new events.Rule(this, 'OrderPlacedToSearch', {
  eventBus: bus,
  eventPattern: { source: ['platform.orders'], detailType: ['OrderPlaced'] },
  targets: [new targets.SqsQueue(searchIndexQueue)],
});
new events.Rule(this, 'AllEventsToArchive', {
  eventBus: bus, eventPattern: { source: [{ prefix: 'platform.' }] },
  targets: [new targets.CloudWatchLogGroup(eventArchive)],   // 30 days, for "what happened?"
});
```

The archive rule is the cheap trick that gives you most of what people want from a stream: a searchable, time-ordered record of every event, [in the same log store as everything else](/en/blog/your-logs-should-not-know-which-cloud), without a stream. It cannot replay into a consumer, but it can answer "did we emit OrderPlaced for order 4412?" in one query, which is the question that actually gets asked.

EventBridge's contract is at-least-once, unordered, with a 256 KB payload limit. Every consumer is idempotent, keyed on the event id, and any event bigger than a few KB carries a pointer to S3, not the payload. Those two rules cover every problem we have had with it.

## The stream: Kinesis, once

The ledger. Every financial movement on the platform, in order per account, projected into balances and reports by a consumer that must be able to be rebuilt from scratch if a projection bug is found. That is the stream-shaped problem: ordering per key, and replay from any point.

One Kinesis stream, one shard, 7-day retention, a Lambda consumer with a checkpoint. When a projection bug was found in month four, the fix was to correct the consumer, reset the checkpoint to the start of retention, and let it re-read three days of events into a fresh table. SQS cannot do that; a consumed message is gone. EventBridge cannot do that; the archive is a log, not a cursor.

<div class="article-figure">
<svg viewBox="0 0 900 240" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="How a checkout request fans out. Checkout handler writes the order and charges the card synchronously in 400 ms, then publishes one OrderPlaced event to EventBridge. Rules route to five SQS queues: email, search index, warehouse sync FIFO, analytics, archive log. Separately, the ledger write goes to a Kinesis stream that the balance projection reads with a replayable checkpoint.">
<defs><marker id="arrK" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L10,5 L0,10 z" fill="#9aa3c7"/></marker></defs>
<g font-family="Inter,system-ui,sans-serif" font-size="11">
<rect x="20" y="70" width="170" height="90" rx="12" fill="#151b2e" stroke="#4fffb0" stroke-width="1.5"/><text x="105" y="94" text-anchor="middle" fill="#4fffb0" font-weight="700">checkout · 400 ms</text><text x="105" y="114" text-anchor="middle" fill="#f1f3ff">write order</text><text x="105" y="130" text-anchor="middle" fill="#f1f3ff">charge card (sync)</text><text x="105" y="148" text-anchor="middle" fill="#9aa3c7">publish 1 event</text>
<line x1="192" y1="100" x2="258" y2="100" stroke="#9aa3c7" stroke-width="1.5" marker-end="url(#arrK)"/><text x="225" y="90" text-anchor="middle" fill="#9aa3c7" font-size="10">OrderPlaced</text>
<rect x="260" y="70" width="150" height="60" rx="12" fill="#151b2e" stroke="#7b8cff" stroke-width="1.5"/><text x="335" y="96" text-anchor="middle" fill="#7b8cff" font-weight="700">EventBridge</text><text x="335" y="114" text-anchor="middle" fill="#9aa3c7">9 rules</text>
<g stroke="#9aa3c7" stroke-width="1.2" marker-end="url(#arrK)"><line x1="412" y1="100" x2="478" y2="30"/><line x1="412" y1="100" x2="478" y2="66"/><line x1="412" y1="100" x2="478" y2="100"/><line x1="412" y1="100" x2="478" y2="134"/><line x1="412" y1="100" x2="478" y2="168"/></g>
<g fill="#151b2e" stroke="#4fffb0"><rect x="480" y="16" width="200" height="28" rx="6"/><rect x="480" y="52" width="200" height="28" rx="6"/><rect x="480" y="86" width="200" height="28" rx="6"/><rect x="480" y="120" width="200" height="28" rx="6"/><rect x="480" y="154" width="200" height="28" rx="6"/></g>
<g fill="#f1f3ff" text-anchor="middle"><text x="580" y="35">SQS · email → Lambda</text><text x="580" y="71">SQS · search index → Lambda</text><text x="580" y="105">SQS FIFO · warehouse, by order id</text><text x="580" y="139">SQS · analytics → Lambda</text><text x="580" y="173">CloudWatch Logs · archive, 30 d</text></g>
<line x1="192" y1="150" x2="258" y2="200" stroke="#ffd166" stroke-width="1.5" marker-end="url(#arrK)"/><text x="215" y="190" fill="#ffd166" font-size="10">ledger write</text>
<rect x="260" y="186" width="150" height="40" rx="12" fill="#151b2e" stroke="#ffd166" stroke-width="1.5"/><text x="335" y="211" text-anchor="middle" fill="#ffd166" font-weight="700">Kinesis · 1 shard</text>
<line x1="412" y1="206" x2="478" y2="206" stroke="#ffd166" stroke-width="1.2" marker-end="url(#arrK)"/>
<rect x="480" y="190" width="200" height="32" rx="6" fill="#151b2e" stroke="#ffd166"/><text x="580" y="211" text-anchor="middle" fill="#f1f3ff">balance projection · replayable</text>
<text x="790" y="100" text-anchor="middle" fill="#9aa3c7">each consumer</text><text x="790" y="116" text-anchor="middle" fill="#9aa3c7">idempotent on event id</text><text x="790" y="132" text-anchor="middle" fill="#9aa3c7">own DLQ, own alarm</text>
<text x="790" y="200" text-anchor="middle" fill="#9aa3c7">the only place</text><text x="790" y="216" text-anchor="middle" fill="#9aa3c7">that needs order + replay</text>
</g>
</svg>
</div>

## The test for "do you need a stream?"

Ask: *if a consumer had a bug last Tuesday, do you need to re-feed it Tuesday's messages in order?* If the honest answer is "we would re-run a batch job against the database", you need a queue and a database, not a stream. If the answer is "yes, and the database does not have the history", you need a stream, for that consumer. One stream, for one consumer, is not a reason to move the whole platform onto Kafka.

## What it costs

| | Monthly | Operations |
|---|---|---|
| SQS, 11 queues + 11 DLQs, ~4 M messages | ~$2 | Zero. Alarms on the DLQs |
| EventBridge, 1 bus, 9 rules, ~1 M events | ~$1 | Zero. Archive rule for "what happened" |
| Kinesis, 1 shard, 7-day retention | ~$15 | Checkpoint monitoring; shard count if it ever matters |
| **Total** | **~$18** | |
| Managed Kafka, smallest useful cluster | $300–600 | Topics, partitions, consumer groups, retention, a broker version to track |

Eighteen dollars and no broker. The platform handles a few million messages a month; SQS would handle a few billion at the same shape, with the bill scaling linearly and nothing to re-architect.

## The short version

Split "queue" into three problems. Work goes to SQS with a dead-letter queue and an alarm. Facts go to EventBridge with a queue per subscriber and an archive rule. History, if you truly have a consumer that must replay in order, goes to one Kinesis stream, for that consumer. Kafka is the right answer when you have many of the third problem, and it is fine to not have that problem.

If your checkout takes four seconds because it does five things, [we can take four of them off the request path in a week](/contact), for about two dollars a month.
