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 Serviceis 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:
-
Initial Event Publication: The
Notification Servicereceives the API request. After basic validation, it publishes a generic event to a main topic, let's call itnotification_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" } } -
The Dispatcher Service: A new, dedicated microservice—let's call it the
Dispatcher—is the sole consumer of thenotification_requeststopic. Its only job is to perform the fan-out logic. -
Fan-Out Logic: For each event it consumes, the
Dispatcher:
a. Calls theUser Preference Serviceto 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 thenotification_templatestable 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.

This design has several advantages:
- Isolation: A problem with the email provider (e.g., SendGrid is down) only affects the
email_dispatch_queueand 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 Workersindependently. - Clarity of Purpose: Each component has a single responsibility.
Notification Serviceingests.Dispatcherroutes.Workersdeliver.
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.
- A client service calls
POST /v1/notificationswith a request to send an "order shipped" notification. OurNotification Serviceimmediately validates it, stores it, and publishes a generic event to thenotification_requestsKafka topic, returning a202 Acceptedto the client. - The
Dispatcherservice, 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." - The
Dispatcherrenders the email and SMS templates and publishes two new messages: one toemail_dispatch_queueand one tosms_dispatch_queue. This is the fan-out. - An
Email Workerand anSMS Workerconsume their respective messages in parallel. - The
SMS Workersuccessfully sends the SMS via Twilio and updates the notification status in our NoSQL database toDELIVERED. - The
Email Worker's attempt to send via SendGrid fails with a503 Service Unavailableerror. - The worker initiates its retry policy. It waits for
1 second + jitter, then tries again. It fails again. It now waits for2 seconds + jitter. On the third try, it succeeds. It updates the notification status toDELIVERED. - 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.