DEV Community

Sarah Lindauer for Stream

Posted on

How Does a Message Queue Power Reliable Chat Delivery?

TL;DR

  • A message queue is a buffer between services. A producer publishes a message, the broker stores it, and a consumer processes it and sends an acknowledgment, so neither service has to be available when the other runs.
  • A queue gives each message to one consumer, and pub/sub gives a copy to every subscriber. Chat delivery uses both.
  • Delivery is at-least-once, so a unique key on recipient ID and message ID makes a redelivered message harmless. The outbox pattern keeps the saved message and its queue event in sync.
  • Queues keep order per key, so the conversation ID is the key. A failing message blocks the ones behind it in that conversation.

Connections drop. Servers restart. The friend you just messaged is away from their desk.

Chat messages are asynchronous by nature. You send one now, and it gets read later, whether that's in two seconds or tomorrow morning. The backend in between is a distributed system, and it works the same way. Each service does its part and passes the message along without waiting for the next one.

A message queue is where that handoff happens. If a connection drops or a server restarts mid-process, the message stays in the queue, and the next attempt picks it up.

What Is a Message Queue?

A message queue is a buffer that services use for asynchronous communication. One service publishes messages to the queue, and the queue holds them until another service consumes and acknowledges them. The two services never call each other directly, so neither has to be available when the other one runs.

A message passes through these components:

  • The producer creates a message and publishes it to the broker. Its request ends when the broker confirms it has stored the message.
  • The message broker is the server that hosts the queue. It persists each message and delivers it to a consumer, either by pushing it over an open connection or by answering the consumer's poll.
  • The consumer is the service that does the work. It receives a message from the broker, processes it, and sends an acknowledgment back.

How a message queue delivers a chat message

The publisher confirm and the consumer acknowledgment are independent. A confirm tells you the broker has the message, and says nothing about whether any consumer has processed it.

Message brokers can be self-hosted or hosted:

  • Self-hosted: RabbitMQ, IBM MQ, Apache ActiveMQ, Apache Kafka
  • Hosted: Amazon SQS, Google Pub/Sub, Azure Service Bus

Your code connects to a broker over a protocol such as AMQP (which RabbitMQ uses) or through an API such as JMS (which Java applications use with IBM MQ).

In a microservices architecture, a new chat message can fan out to several services, such as delivery, push notifications, moderation, and search indexing. If the chat API called each one directly, one slow service would hold up every send. With a queue, the API can enqueue a message and return, keeping the services decoupled.

A queue is also scalable on the consumer side. When messages arrive faster than they're processed, they wait in the queue, and you add more consumers to work through the backlog. The broker balances the load by giving each message to one consumer, so throughput increases without changing the producer.

How Is a Message Queue Different From Pub/Sub?

A message queue delivers each message to exactly one consumer, while publish/subscribe (pub/sub) delivers a copy to every subscriber.

Chat delivery needs both messaging patterns. A new message must reach several services, and a single queue would send it to only one. So each message goes through both:

  1. The backend publishes one MessageCreated event.
  2. Delivery, push notifications, and search indexing each subscribe to that event and get a copy in their own queues.
  3. A pool of workers shares each queue, so one worker handles each copy.

This is an event-driven architecture. The chat API doesn't know which services consume the event, so you can add a subscriber, such as analytics, without changing the API.

How Does a Message Queue Deliver a Chat Message?

The chat API commits the message to its database, a relay publishes it to the message queue, and a delivery worker consumes it and writes it to the recipient’s inbox. Say Alice sends Bob “Can you send me the deployment notes?” from her phone, and Bob has the app open on his laptop. The message goes through these steps:

  1. Alice’s app generates a UUID for the message before sending anything and reuses that ID as the idempotency key on every resend.
  2. The chat API inserts the message and an outbox row in a single database transaction, then responds to Alice.
  3. A relay publishes the outbox row to the broker, keyed by conversation ID, and marks the row as published once it receives the publisher confirm.
  4. The broker delivers the message to one delivery worker and keeps it, unacknowledged, until that worker reports back.
  5. The worker inserts an inbox row for Bob, keyed on recipient ID and message ID, commits, and then acknowledges the message. In a group conversation, this step fans out, creating one inbox row per member.
  6. The worker sends the message to the real-time gateway, which holds Bob’s WebSocket connection and pushes the message to his laptop.

The JSON body of Alice’s request carries the ID her app generated:

{
  "message_id": "019a1d6e-7c2b-7f10-9a44-3b5e2c8d1f07",
  "conversation_id": "chat-42",
  "sender_id": "alice",
  "text": "Can you send me the deployment notes?"
}
Enter fullscreen mode Exit fullscreen mode

If Alice’s connection drops before the response arrives, her app resends the same body. The message ID is the primary key, so the second insert conflicts with the first, and the API returns the message it already stored. The Stream iOS SDK queues a send that fails offline and retries it when the device comes back online, even if the app was killed in between.

Step 2 is the transactional outbox pattern. Inserting the message and then publishing to the broker would be a dual write, with no transaction spanning the database and the broker. If the process crashed after the insert and before the publish, Bob’s delivery pipeline would never receive a message that Alice can see in her history. The outbox pattern stores the event in the same transaction as the message, so the event is sent if and only if the transaction commits.

BEGIN;
INSERT INTO messages (id, conversation_id, sender_id, seq, body)
VALUES (:message_id, 'chat-42', 'alice', 1842, :body);
INSERT INTO outbox (event_id, event_type, message_id, payload)
VALUES (:event_id, 'MessageCreated', :message_id, :payload);
COMMIT;
Enter fullscreen mode Exit fullscreen mode

Alice’s request returns as soon as this transaction commits. Everything after it is asynchronous messaging between backend services, so a slow or restarting worker delays Bob’s copy without failing Alice’s send.

In queue terms, the relay in step 3 is the producer and the delivery worker is the consumer. The relay finds new outbox rows in one of these ways:

  • A polling publisher queries the outbox table at intervals, which works with any SQL database.
  • Transaction log tailing, a form of change data capture, reads the database’s own log, such as the Postgres WAL or the MySQL binlog, and publishes each outbox insert it finds.

Each row becomes one message, with the row’s JSON as the payload and headers that carry metadata such as the message ID and a timestamp.

Message queue diagram

The relay waits for the publisher confirm because a client that has written a publish to its socket can’t assume the message reached the server. It can still crash after the confirm arrives and before it marks the row as published, so that it may publish the same event twice. That’s acceptable, because the worker’s insert in step 5 is written so that a second run changes nothing.

The worker’s ack in step 5 only proves that a backend service finished its write. Knowing whether Bob’s laptop received the message, or whether Bob read it, takes separate events sent from his device.

In Stream Chat, a message is sent when it reaches our server, delivered when the recipient’s device confirms it with a message.delivered event, and read when the recipient marks the channel read. Delivery tracking is off by default, and you turn it on per channel type with delivery_events.

What Happens When a Worker Crashes or a Message Is Delivered Twice?

The broker delivers the message again, so your write must be safe to run twice. If a worker crashes or loses its connection before it acknowledges a message, the broker gives that message to another worker. This is at-least-once delivery, and it’s where a queue’s fault tolerance comes from.

Each message broker has its own trigger for redelivery:

Broker Redelivers when Default timer
RabbitMQ The AMQP channel or connection closes, or the worker nacks with requeue 30-minute ack timeout on quorum queues
Amazon SQS The visibility timeout expires before the message is deleted 30 seconds
Kafka consumer group A new consumer takes over the partition and starts from the last saved position None
Google Pub/Sub The ack deadline passes 10 seconds

Say a worker commits Bob’s inbox row and dies before its ack reaches the broker. The broker can’t see your database. It only knows the ack never arrived, so it gives the message to a second worker, which runs the same insert:

INSERT INTO recipient_inbox (recipient_id, message_id, conversation_id, seq, state)
VALUES ('bob', :message_id, 'chat-42', 1842, 'pending')
ON CONFLICT (recipient_id, message_id) DO NOTHING;
Enter fullscreen mode Exit fullscreen mode

The unique key on (recipient_id, message_id) makes the second insert do nothing; the second worker acks, and Bob sees one message. The ack has to come after the commit. A worker that acks first and crashes before committing tells the broker the work is done, while Bob has no inbox row.

Diagram Showing What Happens When a Worker Crashes or a Message Is Delivered Twice

The alternative is at-most-once delivery. With RabbitMQ’s automatic acknowledgment mode, a message counts as delivered as soon as it’s sent, trading safety for higher throughput, and a worker that crashes loses it. Broker features labeled “exactly once,” such as Kafka’s idempotent producer and SQS FIFO deduplication, only prevent a producer from retrying and writing the message twice. They don’t cover the worker’s database write, so the unique key is still needed.

How Do Message Queues Keep Chat Messages in Order?

Queues keep order per key, so you give every message in a conversation the same key. Chat needs a stable order inside each conversation, and it doesn't matter how messages from chat-42 and chat-91 interleave. Each broker has its own name for that key:

  • Kafka uses the record key. Records with the same key go to the same partition, so set the key to the conversation ID.
  • In Amazon SQS FIFO queues (first-in, first-out), messages that share a MessageGroupId are processed in strict order.
  • Google Pub/Sub has an ordering key, but it only takes effect when you enable message ordering on the subscription.
  • In RabbitMQ, a consistent hash exchange sends all messages with the same routing key to the same queue, and a single active consumer on each queue processes them one at a time.

With strict ordering, a message that keeps failing blocks every message behind it. In an SQS FIFO queue, later messages in the same group are held back while one is in flight, so the conversation stalls until the failing message is moved to the dead-letter queue.

A Kafka partition blocks the same way, because a consumer group's position in each partition is a single integer and it can't skip a failing record and come back to it later. Moving the record to a separate retry topic lets the partition continue, and the newer messages are then processed ahead of it.

Do You Need to Build This Delivery Pipeline Yourself?

Building it means running message queues, an outbox relay, delivery workers, and a sync service for as long as your app has chat. If you'd rather have your engineers working on the rest of your app, Stream Chat runs the server side on our Go backend and global edge network, which we've benchmarked at 5 million concurrent connections in one channel, and our SDKs handle the client side.

When your own backend needs chat events, our webhooks retry failed deliveries and can send events to SQS or SNS. You can set up any of our SDKs from the Chat docs.

Top comments (0)