Building a Fault-Tolerant Communication Service for 150K+ Monthly Events
The Mission: An Unbroken Experience
In February 2024, we faced a challenge that many growing platforms encounter: how do you keep 150,000+ users informed across multiple channels without dropping a single notification?
Our platform was scaling rapidly, generating over 150K transaction events monthly. Users needed to be notified when critical events happened—transactions completed, documents were processed, status updates occurred. But our notification logic was scattered across different services, each implementing its own ad-hoc email sending, leading to inconsistent experiences and, worse, lost notifications.
We needed something better: a centralized communication service that could guarantee delivery across email, WhatsApp, SMS, and push notifications. This is the story of how we built it—and how our architecture evolved when we discovered the first version wasn't robust enough.
The First Attempt: Queue-Driven Architecture
The Core Design
Our initial architecture followed a classic microservices pattern. The design centered around three key components:
- Communication Service - A REST API that internal services could call
- RabbitMQ Task Queues - One queue per channel (email, WhatsApp, SMS, push)
- Workers - Channel-specific processors that interfaced with third-party delivery services
Here's how a notification would flow through the system:
Figure 1: Initial architecture with separate queues for each channel (WhatsApp, Push, SMS, Email). Services A, B, and C route through a load balancer to the Communication Service, which handles authentication, rate limiting, and REST APIs. Each channel has dedicated workers connecting to third-party services.
Building the Foundation
We started with four fundamental architectural decisions:
1. Basic Authentication
We created Basic Auth tokens to secure requests from internal services. Simple, stateless, and sufficient for our internal network.
2. Rate Limiting: The Anti-Spam Shield
Users shouldn't receive duplicate notifications for the same event. We implemented rate limiting with a composite key strategy:
For example: transaction_complete_user123_email would prevent multiple transaction completion emails to user123 within a configured time window. We used Redis to track these keys with TTL-based expiration.
3. REST APIs for Every Channel
The communication service exposed clean REST endpoints:
4. Monitoring with NewRelic
From day one, we instrumented everything. NewRelic gave us visibility into:
- API response times
- Queue depths
- Worker throughput
- Error rates
The Worker Pattern: Manual Acknowledgment
Our workers implemented a sophisticated retry mechanism:
The flow looked like this:
Figure 2: Worker retry mechanism in the initial design. Messages flow from the task queue to workers, which attempt delivery. On success, the message is acknowledged and removed. On failure, the worker retries. After max retries (3), messages move to the Dead Letter Queue, triggering Slack alerts via NewRelic.
The message execution itself was straightforward:
Figure 3: Simple message execution pattern—workers directly communicate with third-party services (like Mailgun for emails) without intermediate logging or state management.
The Database Schema
We designed a simple NotificationSettings table to respect user preferences:
| Column | Type | Description | |--------|------|-------------| | user_id | UUID | User identifier | | channel | ENUM | Email, WhatsApp, SMS, Push | | opted_out | BOOLEAN | User's opt-in status |
The Four-Phase Rollout
We planned an incremental rollout:
Milestone 1: Email Notifications
- Initialize service repository
- Integrate Mailgun
- Set up RabbitMQ email queue
- Build email workers
- Store email templates
Milestone 2: WhatsApp
- Add WhatsApp APIs
- Channel-specific rate limiting (
template_user_whatsapp) - WhatsApp queue and workers
- Third-party WhatsApp integration
Milestone 3: Push Notifications
- Push notification APIs
- Push-specific templates and queue
- Mobile push service integration
Milestone 4: SMS
- SMS APIs and templates
- SMS queue and workers
- SMS provider integration
The Realization: What We Missed
Six days into development, on February 26th, we ran a failure mode analysis. The question that changed everything: What happens when a notification fails after all retries are exhausted?
Our Dead Letter Queue would catch these failures, and NewRelic would alert us. But then what? A human would need to manually investigate and re-queue the message. At 150K+ events per month, this wasn't sustainable.
We identified three critical gaps:
1. No Delivery Guarantee
If the DLQ filled up, we had no automatic recovery mechanism. Failed notifications would require manual intervention.
2. No Priority Differentiation
All notifications were equal in our queues. A transactional email (e.g., "Your payment succeeded") would wait behind marketing emails (e.g., "Check out our new features"). This wasn't acceptable.
3. No Audit Trail
We had no persistent record of notification attempts. Debugging delivery issues meant correlating logs across multiple systems.
The Evolution: Architecture Iteration 1
We went back to the drawing board and designed Iteration 1, a more resilient architecture with three major additions:
Figure 4: Evolved architecture with priority-based queues. Note the key additions: (1) Priority 1 and Priority 2 email queues (blue boxes) for transactional vs. marketing notifications, (2) Database logging for all notification attempts, (3) Scheduler service (top right) that picks failed notifications and re-queues them, (4) Webhook flow (orange) from third-party services back to update notification records.
The Safety Net: Database Logging
Every notification attempt now creates a record:
Workers now log every attempt:
Prioritization: The Hierarchy of Importance
We split each channel into priority-based queues:
RabbitMQ's priority queue feature allowed us to configure this:
The Scheduler: Automated Recovery
The game-changer was our Scheduler Service—a separate service running cron jobs:
The scheduler ran on a configured interval (we started with every 15 minutes) and would:
- Query the database for
failedorpendingnotifications - Check if they're within retry limits
- Re-publish them to the appropriate priority queue
Webhooks: Real-Time Status Updates
Third-party services like Mailgun send webhooks for delivery events:
This gave us real-time visibility into:
- Delivered
- Bounced
- Opened
- Clicked
Figure 5: Enhanced worker flow with database logging. Notice the critical addition: workers now create database records (Step 1) before sending HTTP requests to third-party services (Step 2). The task queue (blue) feeds workers (green), which handle the two-step execution process.
Figure 6: Complete message execution lifecycle in Iteration 1. The green path shows successful database logging. The orange path shows the webhook feedback loop—third-party services send delivery status back to the Communication Service, which updates the database. The scheduler (top) continuously monitors the database for failed/pending notifications and re-queues them to priority queues (right, shown in blue).
The Complete Flow: Iteration 1
Here's how a notification now flows through our improved system:
The Evolutionary Roadmap
Our implementation plan remained largely the same, but Milestone 1 now included the fault-tolerance improvements:
Milestone 1 (Revised): Email with Fault Tolerance
- API Layer: Add email notification endpoint in
paper-communicationservice - Rate Limiting:
- Set up Redis instance
- Implement composite key:
template_name:user_uuid:email
- Queue Infrastructure:
- Initialize RabbitMQ instance
- Create priority-based email queues (3 levels)
- Configure Dead Letter Queue
- Enable RabbitMQ Management UI monitoring
- Worker Implementation:
- Create
notification_logstable - Build email worker with logging
- Store email templates with mapping
- Integrate Mailgun API
- Create
- Webhook Listener: Add POST endpoint for Mailgun webhooks
- Scheduler Service:
- Create cron job (15-minute interval)
- Query failed/pending notifications
- Re-queue logic with priority
- Testing: End-to-end email delivery validation
Milestones 2-4: Expanding Channels
The pattern established in Milestone 1 would repeat for each channel:
- WhatsApp: Similar flow, different third-party integration
- Push Notifications: Mobile-specific templates and delivery
- SMS: SMS provider integration with same fault-tolerance
Technical Takeaways
Building this system taught us several lessons about distributed systems and reliability:
1. Design for Failure from Day One
Our first architecture assumed happy paths. The iteration came from asking: "What breaks, and how do we recover?" This mindset shift is crucial for production systems.
2. Observability is Non-Negotiable
Our layers of monitoring:
- Application: NewRelic for service metrics
- Infrastructure: RabbitMQ Management UI for queue health
- Business Logic: Database logs for audit trails
- External Systems: Webhooks for third-party confirmation
3. Queues Are Not Infallible
Dead Letter Queues catch failures, but you need a mechanism to drain them. Our scheduler was that mechanism—a watchdog that continuously scanned for and recovered failed work.
4. Priority Matters at Scale
Not all notifications are equal. Transactional messages (password resets, payment confirmations) must take precedence over marketing emails. Priority queues solved this elegantly.
5. Rate Limiting is User Respect
The composite key approach (template:user:channel) prevented notification fatigue while still allowing legitimate multi-channel communication.
The Impact
With Iteration 1 in production, we achieved:
- 99.9% delivery success rate for transactional notifications
- < 2 minute p95 delivery latency for high-priority messages
- Zero manual interventions for failed notifications after scheduler deployment
- Complete audit trail for compliance and debugging
The system now handles 150K+ monthly events across four channels, with room to scale to millions as our platform grows. But more importantly, we built a foundation that's resilient—one that doesn't just work when everything goes right, but continues working when things inevitably go wrong.
Conclusion: Architecture as Evolution
This project reinforced a fundamental truth about software engineering: architecture is iterative. We could have tried to build the "perfect" system upfront, but we would have over-engineered or, worse, missed critical failure modes we didn't anticipate.
Instead, we:
- Built a working system quickly (Iteration 0)
- Analyzed its weaknesses honestly (the February 26th pivot)
- Enhanced it with targeted improvements (Iteration 1)
The result is a communication service that's both pragmatic and robust—a system that respects user experience, handles failure gracefully, and provides the observability needed to operate confidently in production.
If you're building similar systems, remember: start simple, instrument everything, and evolve based on real failure modes, not hypothetical ones.
The complete source code and diagrams for this architecture are available in the project repository. For questions or discussions about distributed notification systems, feel free to reach out.