<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[System Design]]></title><description><![CDATA[System Design]]></description><link>https://daysofsystems.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>System Design</title><link>https://daysofsystems.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 02 Sep 2026 22:31:14 GMT</lastBuildDate><atom:link href="https://daysofsystems.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Notification System: From Naïve to Production-Grade]]></title><description><![CDATA[Stack: Node.js · TypeScript · PostgreSQL · BullMQ · RedisUse Case: Food Delivery App (think Swiggy / Zomato)What you'll learn: Why the obvious approach breaks, what replaces it, and how real apps hand]]></description><link>https://daysofsystems.hashnode.dev/building-a-notification-system-from-na-ve-to-production-grade</link><guid isPermaLink="true">https://daysofsystems.hashnode.dev/building-a-notification-system-from-na-ve-to-production-grade</guid><dc:creator><![CDATA[Ayush Bulbule]]></dc:creator><pubDate>Sun, 23 Aug 2026 18:47:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/12741d9a-d493-4311-beb7-f95b77a4a4f3.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<blockquote>
<p><strong>Stack:</strong> Node.js · TypeScript · PostgreSQL · BullMQ · Redis<br /><strong>Use Case:</strong> Food Delivery App (think Swiggy / Zomato)<br /><strong>What you'll learn:</strong> Why the obvious approach breaks, what replaces it, and how real apps handle it at scale.</p>
</blockquote>
<hr />
<h2>The Problem Nobody Talks About</h2>
<p>You open Swiggy, tap "Place Order," and within a second you see:</p>
<blockquote>
<p>✅ <em>Order placed! Raj's Kitchen is preparing your Paneer Burger.</em></p>
</blockquote>
<p>What just happened in the background? Your order was saved to a database — obvious. But simultaneously:</p>
<ul>
<li><p><strong>You</strong> got a push notification + email confirmation</p>
</li>
<li><p><strong>Raj's Kitchen</strong> got an alert to start cooking</p>
</li>
<li><p><strong>A nearby rider</strong> got a pickup request</p>
</li>
</ul>
<p>Three different people. Three different channels. All triggered by one button tap.</p>
<p>And you never waited for any of it.</p>
<p>That's a notification system. And building one that <em>feels</em> instant while reliably delivering to everyone that's the engineering problem.</p>
<p>Let's build it. Twice. First the wrong way, then the right way.</p>
<hr />
<h2>The Cast of Characters</h2>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/61acfbd4-7278-4f24-80cd-06b664acd403.png" alt="" style="display:block;margin:0 auto" />

<p>In our food delivery app, there are three actors:</p>
<table>
<thead>
<tr>
<th>Actor</th>
<th>Role</th>
<th>Channels</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Customer</strong> (Ayush)</td>
<td>Places the order</td>
<td>Email + Push notification</td>
</tr>
<tr>
<td><strong>Restaurant</strong> (Raj's Kitchen)</td>
<td>Accepts and cooks</td>
<td>Email</td>
</tr>
<tr>
<td><strong>Rider</strong> (Vikram)</td>
<td>Picks up and delivers</td>
<td>Push notification</td>
</tr>
</tbody></table>
<p>One event — <code>order.placed</code> — needs to reach <em>different people</em> with <em>different messages</em> on <em>different channels</em>.</p>
<blockquote>
<p><strong>🤔 Stop and think:</strong> At the moment a customer places an order, a rider doesn't exist in the picture yet. When does the rider enter the flow?</p>
</blockquote>
<details>
<summary>Think about it, then expand</summary>
<p>The rider gets notified when the <strong>restaurant accepts</strong> the order — not when it's placed. Swiggy dispatches the rider early so travel time overlaps with food prep time. The rider reaches the restaurant roughly when the food is ready. That's a deliberate optimization, not an accident.</p>
</details>

<hr />
<h2>Part 1 — The Naïve Version</h2>
<h3>The Mental Model</h3>
<p>The simplest thing you can do: when an order is placed, send all notifications <em>before</em> responding to the user.</p>
<p>Simple. Obvious. Works for 10 users. <strong>Completely wrong at scale.</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/30dd2c67-0358-4ad1-8a2c-e838394acbc0.png" alt="" style="display:block;margin:0 auto" />

<p>Let's build it anyway — because you need to <em>feel</em> why it's wrong.</p>
<h3>The Data Model</h3>
<p>Before writing any logic, let's design the tables.</p>
<pre><code class="language-markdown">users                   → id, name, role (customer | restaurant | rider)
channels                → id, user_id, type (email | push), endpoint, is_active
notifications           → id, user_id, event_type, created_at
notification_deliveries → id, notification_id, channel_id, status, attempt_count, sent_at, provider_message_id
</code></pre>
<blockquote>
<p><strong>🤔 Stop and think:</strong> Why are <code>channels</code> and <code>notifications</code> separate tables? What's the difference in their lifecycle?</p>
</blockquote>
<details>
<summary>Think about it, then expand</summary>
<p><strong>Channels</strong> are configuration — created once when a user onboards, rarely change. They represent <em>how</em> to reach someone (email address, FCM token, phone number).</p>
<p><strong>Notifications</strong> are live data — created on every event, constantly changing status. They represent <em>what</em> happened.</p>
<p>Different lifecycles → different tables. Mixing them would mean your channel setup gets cluttered with delivery logs.</p>
</details>

<p>The key insight: for each notification, you need <strong>one row per channel</strong> in <code>notification_deliveries</code>. The transaction model creates the full hierarchy upfront:</p>
<pre><code class="language-text">Order
  ↓
Notification (id=42, user=Ayush, event=order.placed)
  ↓
Notification Deliveries
  ├── Email (channel_id=1, status=sent, attempt_count=1)
  └── Push  (channel_id=2, status=pending, attempt_count=0)  ← can retry independently
</code></pre>
<h3>The Code</h3>
<pre><code class="language-typescript">// POST /order — the naïve handler
app.post("/order", async (_req, res) =&gt; {
  const start = Date.now();

  // Step 1: save order
  const order = { id: 101, item: "Paneer Burger" };

  // Step 2: notify everyone — SYNCHRONOUSLY
  await notify(1, "order.placed");     // customer (email + push)
  await notify(2, "order.placed");     // restaurant (email)

  // Step 3: respond — but only after all notifications sent
  res.json({ success: true, duration_ms: Date.now() - start });
});
</code></pre>
<p>And the notification manager:</p>
<pre><code class="language-typescript">async function notify(userId: number, eventType: string) {
  const userChannels = channels.filter(c =&gt; c.user_id === userId &amp;&amp; c.is_active);

  for (const channel of userChannels) {
    await sendOnChannel(channel);  // ← blocking. one at a time.
  }
}
</code></pre>
<h3>The Sequence — V1</h3>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/f7929cfc-a717-485b-9dbc-def80ea8dfcd.png" alt="" style="display:block;margin:0 auto" />

<h3>The Load Test Results</h3>
<p>We simulated a degraded email provider (5s per send) with 5 concurrent users:</p>
<pre><code class="language-plaintext">user #1 waited → 10.01s
user #2 waited → 10.01s
user #3 waited → 10.01s
user #4 waited → 10.01s
user #5 waited → 10.01s

💀 10 seconds to hear "your order is placed"
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/b3ef7f3b-a506-468e-8064-f183781aec41.png" alt="" style="display:block;margin:0 auto" />

<blockquote>
<p><strong>🤔 Stop and think:</strong> We ran 10 concurrent users and all got responses in ~3s (not 30s). Why didn't they queue up? And why is that <em>still</em> a problem?</p>
</blockquote>
<details>
<summary>Think about it, then expand</summary>
<p>Node.js is async — it handles 10 concurrent requests simultaneously using the event loop. The 10 requests can independently wait on external email-provider I/O without blocking each other. That's why the wall clock is ~3s, not 30s.</p>
<p>But here's the problem: <strong>concurrency ≠ throughput ≠ scalability</strong>.</p>
<p>First, <strong>every single user still waited 3 full seconds</strong> for an order confirmation. Their individual experience was terrible.</p>
<p>Second, while Node.js isn't CPU-blocked, these long-running requests hold open HTTP connections and memory. At sufficiently high concurrency, this synchronous architecture will exhaust server resources, eventually crashing the application.</p>
<p>When the email provider degrades, all users simultaneously jump to 10s. The degradation is instant and total. There is no failure isolation.</p>
</details>

<hr />
<h2>Part 2 — V2: What Breaks in V1 (And Why)</h2>
<h3>The Root Cause</h3>
<p>Everything wrong with v1 has <strong>one root cause</strong>:</p>
<blockquote>
<p>The notification process lives inside the HTTP request lifecycle.</p>
</blockquote>
<p>The user is held hostage until every notification is sent. The notification is not a part of placing an order — it's a <em>reaction</em> to it. Reactions should not block the action.</p>
<h3>Failure Modes</h3>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/cb3408b8-c8d3-4dbf-bd12-2c1fa1db0e15.png" alt="" style="display:block;margin:0 auto" />

<table>
<thead>
<tr>
<th>Failure</th>
<th>What v1 does</th>
<th>What it should do</th>
</tr>
</thead>
<tbody><tr>
<td>Email takes 5s</td>
<td>User waits 5s</td>
<td>User never notices — it's async</td>
</tr>
<tr>
<td>Email provider down</td>
<td>Request hangs forever</td>
<td>Queue retries when provider recovers</td>
</tr>
<tr>
<td>Notification fails mid-batch</td>
<td>Lost forever, no retry</td>
<td>Dead-letter queue, alert on failure</td>
</tr>
<tr>
<td>Server restarts</td>
<td>All in-memory state gone</td>
<td>Persisted in DB, replayed on restart</td>
</tr>
<tr>
<td>100 users × slow email</td>
<td>Server melts</td>
<td>Workers scale independently</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>🤔 Stop and think:</strong> If you had to fix v1 tomorrow with minimal changes, what's the <em>one</em> thing you'd change first?</p>
</blockquote>
<details>
<summary>Think about it, then expand</summary>
<p>Fire-and-forget: respond to the user immediately after saving the order, then process notifications in the background. Even without a queue, just doing <code>notify().catch(err =&gt; log(err))</code> without <code>await</code> would shave 3s off every response.</p>
<p>It's still wrong (no retry, no persistence), but it's the highest-leverage single change.</p>
<p>The <em>right</em> fix is what we'll build in Part 3.</p>
</details>

<p>The obvious first fix: don't wait for notifications inside the request. Push a job to a queue, respond immediately.</p>
<pre><code class="language-plaintext">POST /order
  → Save order to Postgres
  → queue.add({ userId, eventType })   ← fire and forget
  → 200 OK immediately ✅
</code></pre>
<p>Response time drops from 3000ms to milliseconds. Problem solved?</p>
<p>Not quite.</p>
<h3>The Dual-Write Problem</h3>
<p>Postgres and Redis are two independent systems. When you do both sequentially, they are <strong>not atomic</strong>:</p>
<pre><code class="language-sql">INSERT INTO orders ...    ← Postgres commit ✅
queue.add(job)            ← Redis write
</code></pre>
<p>Two failure scenarios:</p>
<table>
<thead>
<tr>
<th>What fails</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td>Postgres commits, Redis is down</td>
<td>Order saved. Notification job <strong>lost forever</strong>. User gets no email.</td>
</tr>
<tr>
<td>Redis succeeds, Postgres rolls back</td>
<td>Notification sent for an order <strong>that doesn't exist</strong>.</td>
</tr>
</tbody></table>
<p>This is the <strong>dual-write problem</strong> — you can't have atomicity across two independent systems without a coordination mechanism.</p>
<p>The fix: make Postgres the single source of truth. Write the order <em>and</em> the notification intent to Postgres in one transaction. Then relay to Redis. That's what the <a href="https://microservices.io/patterns/data/transactional-outbox.html">Outbox Pattern</a> solves.</p>
<hr />
<h2>Part 3 — The Scaled Version</h2>
<h3>The New Mental Model</h3>
<pre><code class="language-plaintext">User taps "Place Order"
  → Save order to DB   ┐
  → Write to outbox    ┘  one atomic transaction
  → ✅ Respond: "Order placed!" (&lt; 50ms)

  Meanwhile, in the background...
  → Relay picks up outbox rows → pushes to queue
  → Worker processes each job → sends notifications
  → Retries automatically on failure
</code></pre>
<p>The user is gone before a single notification is sent. And that's correct.</p>
<h3>The New Architecture</h3>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/3d32cb91-95e2-4706-a0b8-e7513c65c993.png" alt="" style="display:block;margin:0 auto" />

<h3>The Outbox Pattern — A Real-World Analogy</h3>
<p>[Visual: A sticky note board (the outbox) next to a post box. Person writes note → sticks it on board → postman collects from board → delivers. Even if postman is on leave, notes pile up safely on the board]</p>
<p>Imagine you run a restaurant. You get an order. You write it on a slip and put it in the kitchen window (the outbox). The kitchen picks it up when ready. If the kitchen is slammed, slips pile up in the window safely — they don't disappear.</p>
<p>The outbox pattern works the same way:</p>
<pre><code class="language-sql">-- Everything below is ONE atomic transaction.
-- Either all of it commits, or none of it does.
-- Redis can be completely down — the intent is safe in Postgres.

BEGIN;

  -- Step 1: Save the order (the real business action)
  INSERT INTO orders (customer_id, restaurant_id, item)
  VALUES (1, 2, 'Paneer Burger')
  RETURNING id;  -- → order_id = 101

  -- Step 2: Create the parent notification record per recipient
  -- One row per person who needs to be notified
  INSERT INTO notifications (user_id, event_type, status)
  VALUES
    (1, 'order.placed', 'created'),  -- customer (Ayush)
    (2, 'order.placed', 'created');  -- restaurant (Raj Kitchen)
  -- RETURNING id → notification_id = 42, 43

  -- Step 3: Write to outbox — one row per notification
  -- status = 'pending' means: relay hasn't picked this up yet
  -- This is the "sticky note on the kitchen window" — safely stored,
  -- will be processed when the relay/worker is ready
  INSERT INTO outbox (notification_id, user_id, event_type, status)
  VALUES
    (42, 1, 'order.placed', 'pending'),  -- for Ayush
    (43, 2, 'order.placed', 'pending');  -- for Raj Kitchen

COMMIT;
-- ✅ Transaction done. User gets their response immediately.
-- Relay will poll outbox, find these PENDING rows, and push to BullMQ.
-- Even if Redis is down right now — these rows survive. Nothing is lost.
</code></pre>
<blockquote>
<p><strong>🤔 Stop and think:</strong> The relay marks outbox rows as <code>QUEUED</code> only <em>after</em> <code>queue.add()</code> succeeds. Why not before?</p>
</blockquote>
<details>
<summary>Think about it, then expand</summary>
<p>If you mark it <code>QUEUED</code> first, then <code>queue.add()</code> fails (Redis down), the row stays as <code>QUEUED</code> forever. The relay skips it on the next poll. <strong>Notification lost.</strong></p>
<p>By marking <code>QUEUED</code> only after a successful <code>queue.add()</code>, a crash between those two steps means the row stays <code>PENDING</code>. On the next poll, relay tries again. Worst case: you enqueue twice. But that's handled by idempotency.</p>
<p>This is a common pattern: <strong>mark success only after the side effect succeeds</strong>, never before.</p>
</details>

<h3>Idempotency — The Safety Net</h3>
<blockquote>
<p><strong>🤔 Stop and think:</strong> Here's the race condition: relay enqueues a job, then marks it QUEUED. But between those two steps, the relay crashes. On restart, the row is still PENDING. Relay enqueues it again. Now the worker processes it twice. The customer gets two emails. How do you fix this?</p>
</blockquote>
<details>
<summary>Think about it, then expand</summary>
<p><strong>Channel-level Idempotency key</strong> on the worker side. Because a user might have multiple channels (e.g., email and push), the idempotency key must be the combination of <code>notification_id + channel_id</code>.</p>
<p>Before calling an external provider, the worker checks:</p>
<pre><code class="language-typescript">const delivery = await pool.query(
  `SELECT status FROM notification_deliveries
   WHERE notification_id = $1 AND channel_id = $2`,
  [notification_id, channel.id]
);
</code><p><code class="language-typescript">if (delivery.rows[0]?.status === "sent") {
console.log("Already sent on this channel — skipping");
continue; // safe to skip this channel
}
</code></p></pre><p></p>
<p>The two patterns work together:</p>
<ul>
<li><strong>Outbox</strong> guarantees: nothing is <em>lost</em> (durable intent).</li>
<li><strong>Idempotency check</strong> guarantees: retries are safe.</li>
<li>Together: <strong>reliable at-least-once processing</strong>. (True exactly-once is impossible when talking to external APIs like SendGrid, but this is as close as you can get).</li>
</ul>
</details>

<h3>The Outbox Status Machine</h3>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/92faaf0e-510c-4631-97dd-d1310279394d.png" alt="" style="display:block;margin:0 auto" />

<h3>The Notification Delivery Status Machine</h3>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/e1eaec0a-aca0-47b7-abe7-9657363d4fc9.png" alt="" style="display:block;margin:0 auto" />

<h3>BullMQ — Retry With Backoff</h3>
<p>One of the biggest v1 failures: if a notification errors, it's gone. BullMQ handles retry automatically:</p>
<pre><code class="language-typescript">import { Queue } from "bullmq";

// BullMQ queue — connected to Redis
const connection = { host: "localhost", port: 6379 };
export const notificationQueue = new Queue("notifications", { connection });

await notificationQueue.add(
  "send-notification",
  { notification_id, user_id },
  {
    jobId: `notif-${notification_id}`,  // deduplication
    attempts: 3,
    backoff: {
      type: "exponential",
      delay: 1000,  // 1s → 2s → 4s
    },
  }
);
</code></pre>
<p>No retry logic in your code. No manual re-enqueue. If the worker throws, BullMQ retries with exponential backoff. After 3 attempts, it goes to the dead-letter queue where you can inspect and replay.</p>
<h3>V3 Sequence — The Same Order, Done Right</h3>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/146cd575-8746-4ece-983c-b8562a6db7d8.png" alt="" style="display:block;margin:0 auto" />

<h3>Load Test — V3</h3>
<p>10 concurrent users, same degraded email (5s):</p>
<pre><code class="language-text">user #1  →  Fast 200 OK ✅
...
user #10 →  Fast 200 OK ✅

Workers processed all notifications in background.
The HTTP response is no longer coupled to notification-provider latency.
</code></pre>
<p>[Visual: Same bar chart as before, but now response times are flat at &lt;50ms regardless of email speed or concurrent users. The email processing line is separate — in the background — and can spike without affecting the response time line]</p>
<hr />
<h2>Part 4 — How Real Apps Do It</h2>
<h3>Where Our V3 Fits in a Production Architecture</h3>
<p>Our entire v3 notification system is <strong>one microservice</strong> in a production food delivery architecture. Here's how it fits:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/a6dff998-ffbb-4468-b9c3-d581b8d3b31c.png" alt="" style="display:block;margin:0 auto" />

<p>Our v3 is the green box. The Order Service publishes to <strong>Kafka</strong> instead of writing to an outbox directly — Kafka <em>is</em> the durable log. The notification service consumes from Kafka and uses its own internal queue (like BullMQ) for per-channel dispatch.</p>
<h3>What's Different at Scale</h3>
<blockquote>
<p>⚠️ <strong>Transparency note:</strong> The specifics below represent common architectural patterns at high-scale food delivery companies, consistent with publicly available engineering analyses. They are not direct quotes from official engineering posts — treat them as "patterns at this scale" rather than confirmed implementations.</p>
</blockquote>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Our V3</th>
<th>At Swiggy/Zomato Scale</th>
</tr>
</thead>
<tbody><tr>
<td>Message broker</td>
<td>Outbox + BullMQ</td>
<td>Apache Kafka (event-driven, durable log)</td>
</tr>
<tr>
<td>Delivery tracking</td>
<td>Postgres notification_logs</td>
<td>Separate analytics pipeline</td>
</tr>
<tr>
<td>Channel workers</td>
<td>Single worker process</td>
<td>Dedicated microservice per channel</td>
</tr>
<tr>
<td>Retry</td>
<td>BullMQ (3 attempts)</td>
<td>Dead-letter Kafka topics + on-call review</td>
</tr>
<tr>
<td>Fan-out</td>
<td>Synchronous in transaction</td>
<td>Stream processing (Kafka Streams / Flink)</td>
</tr>
<tr>
<td>Rider dispatch</td>
<td>Hardcoded rule</td>
<td>ML model picks the optimal rider</td>
</tr>
</tbody></table>
<h3>One Real Named System — Swiggy's Klaxon</h3>
<p>Swiggy has publicly documented <strong>Klaxon</strong>, an in-house real-time alerting platform, on their engineering blog <em>Swiggy Bytes</em>:</p>
<blockquote>
<p><em>Klaxon processes over 1.5 million internal alerts daily using Complex Event Processing (CEP) — triggering automated SOPs when anomalies are detected (e.g., delayed orders, partner downtime).</em></p>
</blockquote>
<p><strong>Important distinction:</strong> Klaxon handles <em>internal operational alerts</em> (engineering and ops teams) — not customer-facing push/email notifications. It's a different audience for the same underlying problem. Worth knowing about because it shows that even internal notification systems need dedicated infrastructure at scale.</p>
<blockquote>
<p><strong>🤔 Stop and think:</strong> In our model, when <code>order.placed</code> fires, the notification manager explicitly knows to notify customer + restaurant. In a Kafka-based model, who decides that?</p>
</blockquote>
<details>
<summary>Think about it, then expand</summary>
<p>No one in the order service decides. The order service just <strong>publishes the event to a topic</strong> and forgets.</p>
<p>The notification service, analytics service, and inventory service each <strong>subscribe independently</strong> to that topic. Each service decides for itself what to do with the event.</p>
<p>This is true decoupling. Adding a new service that reacts to <code>order.placed</code> requires zero changes to the order service. It just subscribes to the Kafka topic.</p>
<p>This is why pub/sub at the inter-service level is fundamentally different from a task queue within a service.</p>
</details>

<hr />
<h2>Summary — The Mental Models</h2>
<h3>V1 → V3 in One Diagram</h3>
<p>Naive (v1)</p>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/2fc006ca-ea35-47d9-9a34-13ae0f1dae8a.png" alt="" style="display:block;margin:0 auto" />

<p>Scaled (v3)</p>
<img src="https://cdn.hashnode.com/uploads/covers/63ecc3d07cedde50200b75f5/f0f236bb-ee37-4ec0-92bd-6c57a97dabde.png" alt="" style="display:block;margin:0 auto" />

<h3>The Three Principles</h3>
<p><strong>1. Notifications are reactions, not actions.</strong> They should not block the user-facing request, but the business event that triggers them must be durably recorded. A notification system isn't primarily a sending problem; it's a reliability problem around asynchronous side effects.</p>
<p><strong>2. Outbox + Idempotency = Reliable At-Least-Once Processing</strong> Outbox provides durable intent (at-least-once publication). Idempotency makes retries safe. True exactly-once delivery to external providers (like an email API) cannot be perfectly guaranteed, but this combination ensures safe, reliable processing without observable duplicates.</p>
<p><strong>3. Each failure mode demands a separate mechanism</strong></p>
<ul>
<li><p>Lost notification intent → Transactional Outbox</p>
</li>
<li><p>Duplicate processing → Channel-level Idempotency keys</p>
</li>
<li><p>Slow channel → Async workers</p>
</li>
<li><p>Channel down → Retry with backoff</p>
</li>
<li><p>Total failure → Dead-letter queue</p>
</li>
</ul>
<hr />
<h2>Open Questions (For Your Next Deep Dive)</h2>
<ul>
<li><p>[ ] <strong>Notification preferences:</strong> How does the user opt out of email but keep push? Where does that filter live — in the outbox, the worker, or the channel table?</p>
</li>
<li><p>[ ] <strong>Fan-out at scale:</strong> If 1 million users follow a restaurant and it goes live — how do you fan-out 1M notifications without blocking? (Hint: fan-out on write vs fan-out on read)</p>
</li>
<li><p>[ ] <strong>Ordering guarantees:</strong> Does it matter if a user gets the "order shipped" notification before "order placed"? How do you prevent out-of-order delivery?</p>
</li>
<li><p>[ ] <strong>Read receipts:</strong> How does an app know you've <em>seen</em> a notification? What infrastructure enables that feedback loop?</p>
</li>
<li><p>[ ] <strong>Multi-region:</strong> Your notification worker is in Mumbai. The user is in Delhi. The FCM token is registered to a US data center. How many network hops is that?</p>
</li>
</ul>
<hr />
<h2>References</h2>
<ol>
<li><p><strong>Transactional Outbox Pattern</strong> — Chris Richardson, Microservices.io <a href="https://microservices.io/patterns/data/transactional-outbox.html">https://microservices.io/patterns/data/transactional-outbox.html</a> <em>Canonical definition of the pattern, dual-write problem, relay strategies, and at-least-once delivery guarantees.</em></p>
</li>
<li><p><strong>BullMQ Documentation</strong> — Rate Limiting, Job Priority, Concurrency [<a href="https://docs.bullmq.io%5C%5D">https://docs.bullmq.io\]</a> <em>Official docs for the queue library used in v3. See rate limiting and priority sections.</em></p>
</li>
<li><p><strong>Microservices Patterns</strong> — Chris Richardson (Book, 2018) Manning Publications. ISBN: 978-1617294549 <em>Chapter on messaging covers outbox, idempotency, and exactly-once processing in depth.</em></p>
</li>
<li><p><strong>Enabling Real-Time Business Monitoring with Klaxon</strong> — Swiggy Bytes (Official Engineering Blog) [<a href="https://bytes.swiggy.com%5C%5D">https://bytes.swiggy.com\]</a> <em>Swiggy's internal CEP-based alerting platform processing 1.5M alerts/day. Note: internal alerting, not customer-facing notifications.</em></p>
</li>
<li><p><strong>Designing Scalable Notification Systems</strong> — Aditya Goel, Medium [<a href="https://adityagoel123.medium.com/designing-scalable-notification-system-79f83272755e%5C%5D">https://adityagoel123.medium.com/designing-scalable-notification-system-79f83272755e\]</a> <em>Complementary system design overview covering requirements, SLAs, and architectural patterns.</em></p>
</li>
</ol>
]]></content:encoded></item></channel></rss>