Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 29

Designing an Asynchronous Notification System with Message Queues

Recommended resources

Introduction

In our last lesson, we laid the critical groundwork for our notification system by designing the API contract and the underlying data model. We established how client services would interact with our system and where we would store essential data like user preferences and templates. We concluded with the idea that when a notification request is received, the Notification Service would place a job onto a message queue, transitioning from a synchronous API call to an asynchronous process.

Today, we'll design that asynchronous workflow. This is where the core logic of a scalable, resilient notification system resides. We will architect the "how" of taking a single request and reliably dispatching it across multiple channels, handling failures gracefully along the way. Since you've mentioned that message brokers and distributed system patterns are new areas for you, this lesson will focus on building a strong, practical understanding of these concepts.

By the end of this lesson, you will be able to architect the asynchronous workflow for the notification system using a message queue to handle fan-out, retries, and ensure reliable delivery.


Step 1: Why a Message Queue is Non-Negotiable

In the previous lesson's architecture, we placed a message queue after the Notification Service. Let's solidify why this is a critical design choice. A naive approach might be for the Notification Service to directly call the external APIs for email, SMS, and push notifications. This synchronous approach has several major flaws:

  • Poor Latency & User Experience: The client would have to wait for our service to get responses from all external providers (e.g., Twilio, SendGrid). If one of them is slow, the client's request is slow.
  • Lack of Resilience: If an external provider's API is down, the notification fails permanently. There is no built-in mechanism to retry.
  • Tight Coupling: The Notification Service is tightly coupled to the logic and availability of every channel provider. Adding a new channel would require a deployment of the core service.

A message queue solves these problems by acting as a durable buffer. It decouples the initial request (the "producer") from the final processing (the "consumers").

System Design Interview - Notification Service

To understand the role of this central buffer, let's watch a segment from the 'System Design Interview - Notification Service' video. It frames the message queue as a 'Temporary Storage' service and discusses its essential characteristics.

Watch from 11:15 to 14:41. Pay close attention to the evaluation of different storage options (NoSQL, in-memory, message queues). Note why a distributed message queue like Kafka or AWS SQS emerges as a strong candidate for this use case, emphasizing speed, availability, and persistence.

As the video explains, a message queue isn't just a temporary holding place; it's a foundational component for building a scalable and resilient system.

How to Design a Notification System: A Complete Guide

The article 'How to Design a Notification System' provides a concise summary of the benefits and choices for message brokers. This will help solidify the 'why' and introduce the 'which'.

Read the section titled 'Message Queues and Brokers'. Focus on the three key benefits (decoupling, buffering, backpressure) and the high-level comparison between Kafka, RabbitMQ, and managed services like SQS. This is a common interview discussion point.

Key Takeaway: Using a message queue allows us to immediately acknowledge a client's request (202 Accepted) and guarantee that the notification will be processed asynchronously, even if downstream services are slow or temporarily unavailable.


Step 2: Architecting the Fan-Out Workflow

Our system needs to handle a single event that might trigger notifications on multiple channels. This is known as the fan-out pattern. For example, a single POST /v1/notifications request for an "order shipped" event could result in an email, an SMS, and a push notification being sent to the user.

A robust way to implement this is with a two-tiered queuing system: a central topic for initial events and dedicated queues for each channel.

Here's the workflow:

  1. Initial Event Publication: The Notification Service receives the API request. After basic validation, it publishes a generic event to a main topic, let's call it notification_requests. The payload contains all necessary information.

    // Event published to the 'notification_requests' topic
    {
      "notification_id": "notif-abc-456",
      "user_id": "user-xyz-123",
      "tenant_id": "tenant-def-789",
      "template_id": "order_shipped_v2",
      "data": { "name": "Alex", "order_id": "ORD-98765" }
    }
    
  2. The Dispatcher Service: A new, dedicated microservice—let's call it the Dispatcher—is the sole consumer of the notification_requests topic. Its only job is to perform the fan-out logic.

  3. Fan-Out Logic: For each event it consumes, the Dispatcher:
    a. Calls the User Preference Service to get the user's preferred channels for this type of notification.
    b. For each enabled channel (e.g., email, push):
    i. Fetches the correct template from the notification_templates table in our PostgreSQL database.
    ii. Renders the final message content (e.g., "Hello Alex, your order ORD-98765 has shipped!").
    iii. Publishes a new, highly specific event to a channel-specific queue (e.g., email_dispatch_queue, push_dispatch_queue).

This architecture is visually represented in the diagram below.

Multi-Channel Notification System Architecture with Message Queues
This diagram shows the asynchronous workflow. An event is published to a message broker (like SNS/Kafka), which then fans out to channel-specific queues (Email, SMS, Push). Dedicated workers for each channel consume from these queues to perform the final delivery.

This design has several advantages:

  • Isolation: A problem with the email provider (e.g., SendGrid is down) only affects the email_dispatch_queue and its workers. SMS and Push notifications continue to be processed without issue.
  • Independent Scaling: If you send far more push notifications than SMS messages, you can scale the number of Push Notification Workers independently.
  • Clarity of Purpose: Each component has a single responsibility. Notification Service ingests. Dispatcher routes. Workers deliver.

Step 3: Ensuring Reliable Delivery with Retries and DLQs

Now that a specific job is in a channel queue (e.g., an email job in email_dispatch_queue), a worker pulls the message and attempts to deliver it by calling an external API. But what happens if that API call fails? To ensure at-least-once delivery, we must implement a retry mechanism.

Retry Strategy: Exponential Backoff with Jitter

Simply retrying immediately is a bad idea. If the external service is overloaded, retrying instantly will only make things worse, contributing to a "thundering herd" problem. A much better approach is exponential backoff with jitter.

  • Exponential Backoff: The delay before retrying increases exponentially with each failed attempt (e.g., 1s, 2s, 4s, 8s...). This gives the struggling downstream service time to recover.
  • Jitter: A small, random amount of time is added to each backoff period. This prevents thousands of clients from retrying at the exact same moment after an outage, spreading out the load.

How to Design a Notification System: A Complete Guide

The article 'How to Design a Notification System' provides an excellent, practical explanation of this retry strategy, including a code snippet that you can adapt for any language.

Read the subsections 'Delivery Semantics', 'Retry Strategy', and 'Dead-Letter Queue (DLQ)'. Focus on how 'At-Least-Once' delivery combined with idempotency is a practical choice. Study the backoff algorithm; being able to write or pseudo-code this in an interview is very impressive.

The Consequence of Retries: Idempotency

At-least-once delivery means a message might be delivered more than once. For example, our worker sends an email, the email provider's server processes it but times out before sending us a success response. Our worker, thinking the request failed, will retry and send the same email again.

To prevent this, the producer of the request must provide an idempotency key. We already designed this into our POST /v1/notifications API in the last lesson. The worker should pass this key along to the external provider if their API supports it. If not, our system must handle deduplication internally before sending.

Failure Handling: The Dead-Letter Queue (DLQ)

What if a message repeatedly fails even after all retries (e.g., the email address is invalid, 5xx error from the provider)? We can't let it clog the main queue forever.

This is where a Dead-Letter Queue (DLQ) comes in. After a configurable number of retries, the message broker automatically moves the "poison pill" message to a separate DLQ.

  • Purpose: The DLQ isolates persistently failing messages so that healthy messages can continue to be processed.
  • Action: Messages in the DLQ trigger alerts for an on-call engineer to investigate. The issue might be a bug in our worker, a permanent failure with the external provider, or bad data in the request. These messages can then be manually re-processed or discarded.

The Complete Asynchronous Workflow

Let's trace a request through the entire system we've just designed.

  1. A client service calls POST /v1/notifications with a request to send an "order shipped" notification. Our Notification Service immediately validates it, stores it, and publishes a generic event to the notification_requests Kafka topic, returning a 202 Accepted to the client.
  2. The Dispatcher service, consuming from this topic, picks up the event. It fetches the user's preferences, which say "send transactional updates via email and SMS, but not push."
  3. The Dispatcher renders the email and SMS templates and publishes two new messages: one to email_dispatch_queue and one to sms_dispatch_queue. This is the fan-out.
  4. An Email Worker and an SMS Worker consume their respective messages in parallel.
  5. The SMS Worker successfully sends the SMS via Twilio and updates the notification status in our NoSQL database to DELIVERED.
  6. The Email Worker's attempt to send via SendGrid fails with a 503 Service Unavailable error.
  7. The worker initiates its retry policy. It waits for 1 second + jitter, then tries again. It fails again. It now waits for 2 seconds + jitter. On the third try, it succeeds. It updates the notification status to DELIVERED.
  8. If the email had failed after 5 retries, the message broker would have automatically moved the message to the email_dlq, and a monitoring alert would have been triggered.

Conclusion

In this lesson, we designed the dynamic core of our notification system. We moved beyond simple API and data models to architect a robust, asynchronous workflow that can handle high volume and gracefully manage failure.

Key Takeaways:

  • Decouple with Queues: Message queues are essential for building scalable and resilient backend systems. They decouple producers from consumers, absorb traffic spikes, and enable asynchronous processing.
  • Fan-Out for Parallelism: Use a dispatcher and channel-specific queues to handle multi-channel notifications. This isolates failures and allows for independent scaling of your delivery workers.
  • Reliability through Retries: Implement exponential backoff with jitter to handle transient failures without overwhelming downstream services.
  • Plan for Persistent Failure: Use a Dead-Letter Queue (DLQ) to isolate messages that repeatedly fail, ensuring they don't block the main processing pipeline and can be inspected for debugging.

Preview of the Next Lesson:

We have now designed the API, the data model, and the core asynchronous workflow. In our next lesson, we will put it all together. We will sketch the complete high-level architecture, integrating all components, and discuss potential bottlenecks and scaling strategies. This will involve analyzing the end-to-end system for single points of failure, performance limitations, and how we can scale each part to handle millions of notifications.