Design Projects: Concurrent Systems · Pub-Sub Message Queue

Design a Pub-Sub Message Queue

Designing a message broker: topic logs, consumer groups with offsets, and delivery guarantees.

  • 7 min read
  • 15 practice questions
  • 2 graded problems
On this page
  1. Description
  2. The approach
  3. The pieces in code
  4. Delivery guarantees
  5. Ordering and scale
  6. Where you'll see it
  7. Which tool for which job?
  8. Common mistakes
  9. Check yourself
  10. Key takeaways
  11. Quick revise
  12. Practice

Description

A publish/subscribe queue lets services react to events - "an order was placed" - without the publisher knowing who listens, and without a slow or crashed listener losing messages. In design rounds it's usually a small Kafka: an append-only log per topic, consumer groups each with their own position (offset), commits after processing, and partitions for ordering and scale. This skill builds that model and works through delivery guarantees and ordering.

"Services should react when an order is placed" often starts as the Observer pattern:

Java

List<OrderListener> listeners = new ArrayList<>();

void placeOrder(Order order) {
    save(order);
    for (OrderListener l : listeners) l.onOrderPlaced(order);   // email, analytics, invoicing...
}

Python

listeners: list[OrderListener] = []

def place_order(order: Order) -> None:
    save(order)
    for listener in listeners:
        listener.on_order_placed(order)   # email, analytics, invoicing...

It works until the email service is slow - then checkout is slow, because the listeners run on the caller's thread. If a listener throws, the loop stops and later listeners never hear about the order. A service that's restarting misses every order placed meanwhile, and nothing can replay history for a new service or after a bug fix. Moving to a queue that deletes each message once read fixes the timing, but now only one of the services gets each order.

We'll go from simple to more technical:

  1. The model: topics as append-only logs, consumer groups with committed offsets.
  2. Publishing, polling and committing.
  3. Delivery guarantees: at most once, at least once, effectively once.
  4. Ordering and scale with partitions, and the bugs that lose or duplicate messages.

The approach

  1. Clarify. Should every interested service get every message (fan-out), or should several workers share one stream (competing consumers) - or both? Does order matter, and per what (per user, per order)? What must happen if a consumer crashes mid-message? Retention and replay? Pull or push?
  2. Model. A Message(topic, offset, payload). Per topic, an append-only log. Per topic and consumer group, a committed offset. A Broker that owns them.
  3. API. publish(topic, payload) returns the offset; poll(topic, group, max) returns the next messages from the group's committed offset; commit(topic, group, nextOffset); lag(topic, group).
  4. Core. Reading never deletes. Each group moves forward only by committing, so an uncommitted message is delivered again. Offsets are assigned under the same lock as the append, and commits only move forward.

The pieces in code

Java

public synchronized long publish(String topic, String payload) {
    List<Message> log = log(topic);
    Message message = new Message(topic, log.size(), payload);   // the offset and the append happen together
    log.add(message);
    return message.offset();
}

public synchronized List<Message> poll(String topic, String group, int max) {
    List<Message> log = log(topic);
    int from = (int) committedOffset(topic, group);              // a new group starts at 0
    int to = (int) Math.min(log.size(), (long) from + max);
    return List.copyOf(log.subList(from, to));                   // a copy, never a view of the log
}

public synchronized void commit(String topic, String group, long nextOffset) {
    // ... validate 0 <= nextOffset <= log size ...
    committed.get(topic).merge(group, nextOffset, Math::max);    // commits never move backwards
}

Python

def publish(self, topic: str, payload: str) -> int:
    with self._lock:
        log = self._log(topic)
        message = Message(topic, len(log), payload)          # the offset and the append happen together
        log.append(message)
        return message.offset

def poll(self, topic: str, group: str, max_count: int) -> list[Message]:
    with self._lock:
        log = self._log(topic)
        start = self._committed_offset(topic, group)          # a new group starts at 0
        return log[start:start + max_count]                   # a slice is already a copy

def commit(self, topic: str, group: str, next_offset: int) -> None:
    with self._lock:
        ...  # validate 0 <= next_offset <= len(log)
        committed = self._committed[topic]
        committed[group] = max(committed.get(group, 0), next_offset)   # commits never move backwards
  • Offset and append together. Reading log.size() and appending in two unguarded steps lets two publishers get the same offset, or one message overwrite another.
  • poll doesn't move the group. Only commit does, so a consumer that crashes before committing gets the same messages again - at-least-once delivery.
  • A copy, not a subList view. A view would let a consumer change the broker's log, and would break with ConcurrentModificationException as soon as the log grows. (A Python slice is always a copy, so log[start:end] gets this right for free.)
  • merge(..., Math::max) ignores a late, lower commit - one that arrives after a later one - which would otherwise rewind the group and flood it with redeliveries.

Delivery guarantees

When the consumer commits, relative to processing, decides what a crash costs:

CommitA crash between the twoGuarantee
before processingthe message is skippedat-most-once
after processingthe message is processed againat-least-once
atomically with the resultneitherexactly-once (effectively)

At-least-once is the usual choice, which makes idempotent consumers mandatory: remember the ids (or offsets) of messages already applied, or make the effect naturally repeatable ("set status to PAID" rather than "add 10 to balance"). A poison message - one that fails every time - would block its group forever under at-least-once, so after a few attempts it's moved to a dead-letter topic for a human to look at.

Ordering and scale

A single log is totally ordered, but only one consumer per group can read it in order. Real brokers split a topic into partitions, each its own log, and send a message to a partition by hashing its key (an order id, a user id). Order then holds per key - all events for order 42 stay in sequence - and a group scales out by giving each consumer some of the partitions. Global order across partitions is gone, which is usually fine once you pick the key well.

Where you'll see it

This project is a small Kafka: topics made of partitioned logs, consumer groups with committed offsets, retention by time or size, and replay by resetting offsets. Queue systems such as RabbitMQ and Amazon SQS take the other design: a message goes to one consumer and is deleted when acknowledged (SQS hides a received message for a visibility timeout and redelivers it if it isn't deleted in time; after too many receives a redrive policy moves it to a dead-letter queue). Push-based brokers must handle backpressure - a slow consumer can't be sent messages faster than it processes them, so they cap in-flight messages per consumer; pull-based consumers set their own pace.

Which tool for which job?

You needUse
Several independent readers of the same eventsconsumer groups, each with its own offset
Work shared among instances of one serviceseveral consumers in one group, partitions split between them
Per-customer ordering with parallelismpartition by key (customer id)
Never lose a messagecommit after processing (at least once) + idempotent consumers
Replay historya retained log, reset the group's offset
A message that always failsretries, then a dead-letter topic

Common mistakes

MistakeWhat happensDo this instead
Committing before processinga crash loses the messagecommit after processing
Non-idempotent consumers with at-least-once deliveryduplicates double-charge or double-sendde-duplicate by message id or offset
One partition for a high-volume topicone consumer at a time; no parallelismpartition by a key
Random partitioning when order mattersone customer's events processed out of orderpartition by that customer's id
Retrying a poison message foreverthe partition stops behind itbounded retries, then a dead-letter topic

Check yourself

Try to answer before opening each one - recalling beats re-reading.

1. The email service and the analytics service both need every order event; the email service runs three instances. How many consumer groups, and why?

Show answer

Two: one per service. Each group has its own committed offset, so both services see every event (fan-out). The three email instances share one group, so each event is handled by one of them, not three - that's how a group scales out.

2. A consumer commits the offset, then charges the customer, and crashes between the two. What happens to that payment - and what if it charges first, then commits?

Show answer

Commit first: the group has moved past the message, so after the restart it's never processed - the customer is never charged (at-most-once). Charge first: the message is delivered again after the restart and the customer can be charged twice (at-least-once) - unless the charge is idempotent, e.g. keyed by the message id.

3. Why must publish assign the offset and append the message under one lock?

Show answer

Because "offset = current size" and "append" are two steps. Without the lock, two publishers can read the same size, both claim the same offset, and one message is lost or the log's offsets stop matching positions. Doing both inside one critical section (or with an atomic append that returns the position) makes every offset unique and contiguous.

Key takeaways

  • A broker decouples producers from consumers in time: append-only logs, and consumers that pull at their own pace.
  • Each consumer group has its own committed offset: fan-out across groups, shared work within one, replay by moving the offset.
  • poll doesn't advance; commit does, and never backwards - that's at-least-once, so consumers must be idempotent.
  • Order holds per log or partition; choose the partition key so the events that must stay in order share one.
  • Assign offsets inside the append's critical section, return copies, and watch lag against retention.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Pick the tool

You needUse
Independent readersconsumer groups with their own offsets
Shared workseveral consumers in one group
Per-key orderingpartition by key
No lost messagescommit after processing + idempotent consumers
Poison messagesbounded retries, retry topics, a dead-letter topic
Events from a DB writethe transactional outbox

Snippets worth knowing by heart

Java

List<Message> batch = broker.poll("orders", "billing", 100);
for (Message m : batch) {
    if (!processed.contains(m.id())) {         // idempotent: skip redeliveries
        charge(m);
        processed.add(m.id());
    }
}
broker.commit("orders", "billing", batch.get(batch.size() - 1).offset() + 1);   // after processing

Python

batch = broker.poll("orders", group="billing", max_messages=100)
for m in batch:
    if m.id not in processed:                  # idempotent: skip redeliveries
        charge(m)
        processed.add(m.id)
broker.commit("orders", group="billing", offset=batch[-1].offset + 1)   # after processing

The traps

  • Commit before processing - lost messages.
  • Non-idempotent consumers - duplicate effects.
  • One partition - no parallelism.
  • Random partitioning - order lost.
  • Endless retries - a blocked partition.

Staff-level points, one line each

  • Exactly once = effectively once: broker transactions inside, idempotency (offset stored with the effect) outside.
  • Watch consumer lag; lag near retention means data loss.
  • Hot partitions: the top customer sent 23.1% of messages; the busiest partition carried 2.2x average.
  • acks=all + min.insync.replicas=2; idempotent producers stop retry duplicates and reordering.
  • Rebalances redeliver uncommitted messages; cooperative rebalancing moves less.
  • Retry topics with backoff, then a dead-letter topic - at the cost of per-key order.
  • Outbox or CDC to publish with a DB write; compatible schema changes via a registry.

Say it in an interview

  • "Each consumer group has its own offset, so billing and email read independently."
  • "We commit after processing, so consumers de-duplicate by message id."
  • "Events are partitioned by customer id, so each customer's events stay in order."
  • "The order and its event are written in one transaction through an outbox."

Practice

This skill comes with 15 multiple-choice questions that tell you which misconception a wrong answer reveals and 2 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Design a Pub-Sub Message Queue free

Deliver Each Producer's Messages in Order

MediumProCoding problemJava · Python

Messages from several producers arrive over an unreliable network: out of order, and sometimes twice. Each producer numbers its messages 1, 2, 3, ..., and the consumer must process each producer's messages in order and exactly once.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Build a Pub-Sub Message Broker

HardProDesign projectJava · Python

Build a small log-based publish-subscribe broker, in the style of Kafka. Producers append messages to topics; each topic is an append-only log with numbered positions (offsets).

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Design a Pub-Sub Message Queue free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime