August 28, 2026 · 6 min read
SNS-SQS and Kafka, are they the same?
Support could find the sale order. The dashboard said zero orders today. Both jobs were reading the same checkout, and one queue quietly decided only one of them would get it.
Support could look up the order, and the dashboard still said zero phones sold today. Both jobs were reading the same festival-sale checkout, and I had written both of them myself.
A phone in the sale
The buyer checks out the phone, and the API does two things, in this order.
First it writes the order row, o1, to the database. That row is the fact. If that write fails there is no order.
Then it announces what happened: a small event with the order id, the user, and the total. The API does not call search. It does not call the dashboard. It returns success.
Two other jobs care. Search wants o1 in its index so support can find the order. A counter wants to add one to the orders-today total.
The dashboard that stayed at zero
The thing in the middle was a single queue. Both jobs read from it. In my head two consumers meant both would see o1.
Only one of them did.
A queue of this kind is a work inbox. A worker asks for work (polling). The queue hands it a message and hides that message from everybody else for a while. The worker does the job and then deletes the message. Then it is gone. If the worker crashes before deleting, the message reappears after a timeout. That reappearance is why delete is a separate step: it is how the queue knows the work finished, not just started.
Search polled first and was handed o1. It wrote the index. It deleted the message. The counter polled a moment later and found an empty queue. Support could find the order. The dashboard stayed at zero.
Nothing malfunctioned. The queue behaved as documented. My mistake was what I thought a consumer was.
What a queue is actually for
Two search processes on that queue would have been fine. They race for whatever arrives next. Each order gets indexed once. That is what a work queue is built for: one job, more hands, and the queue making sure two hands do not do the same task twice.
Search and a counter are not that. They are two different jobs that both need the same event. The queue hands each message to one worker.
The workaround I tried first was to run both functions inside one process: poll once, then index and count. That works until there are two replicas of that process, or until one crashes halfway and the message comes back. Then the counter increments twice. The bug moved.
Leaving the message in the queue does not help either. Not deleting means “this job is not finished yet.” It does not mean the counter can come back tomorrow and read the same checkout. A queue holds pending work, not history.
Giving each job its own inbox
The first real fix is to stop sharing the queue.
SNS is a bus, not an inbox. The API publishes o1 to a topic, once. SNS copies that event into every queue subscribed to the topic. Search gets its own queue with its own copy. The counter gets its own queue with its own copy.
Now both jobs work. Search polls, indexes o1, deletes its copy. The counter polls, writes orders-today as 1, deletes its copy. Neither deletion is visible to the other.
Adding a third job later means another subscription and another queue. Fan-out is done by duplication. The number of copies grows with the number of jobs.
Keeping one copy and two bookmarks
The other fix keeps a single copy of the event and changes who is allowed to read it.
The API publishes o1 to a Kafka topic called orders. Kafka writes the event to a file and leaves it there. Reading does not remove it. Search is one consumer group and the counter is a different group. A group is one job, which may be several processes, and each group keeps its own bookmark. That bookmark is a cursor.
Both groups read o1, because reading is only moving your own bookmark. The event is still in the file afterwards, available to whatever job gets written next month.
If search is down for an hour, checkout still returns success. The row is in the database and the event is on the topic. When search comes back, it reads on from its cursor. The counter never waited for it.
How long o1 survives is set by time or disk space, not by whether anybody has read it. Inside a group, several processes still split the work: only one of them indexes this particular order. The group is the job. A process inside it is another pair of hands.
Kafka also slices a topic into partitions. That is how it scales and where order actually lives. I wrote that separately. None of it changes the choice here.
Which one I reach for
A queue on its own when o1 is a task and exactly one worker should perform it. Extra workers just share the load.
SNS with a queue per job when queues are already the shape of the system and a second job needs the same events. One publish, two copies, two deletions.
Kafka when several jobs will keep reading the same checkouts, including jobs that do not exist yet. One log and a cursor per job. A new job is a new group, and replaying history is just moving a cursor back.
An inbox belongs to a job, not to a repository. Search and the counter shared a queue because they shared a codebase. The queue had no idea it was picking a winner between two jobs.
A filter on an SNS subscription that drops o1 from the counter’s queue leaves search healthy and the dashboard at zero. Same bug from the outside.
Giving two Kafka services the same group id does it too. Same group means one job, so they divide the partitions and each sees roughly half the events. That is the single-queue bug rebuilt on a log.
“The log keeps everything” only holds until retention expires. Then o1 is gone for every job at once.
I am done putting search and a counter on one queue and calling it fan-out.
If this is useful, wrong, or incomplete, write to me.
