Building a Multi-Channel Notification Engine: Email, Push, and In-App Without the Spaghetti
A user-facing notification system is the kind of feature every SaaS builds twice: once as a quick loop of raw SQL and process.send, and once as a proper delivery engine after the batch sender hits a rate limit and silently drops a thousand critical alerts. Here is the production-grade Node.js notification engine that handles channel routing, template rendering, idempotency, rate limiting, and delivery tracking across email, push, and in-app channels.
The first notification system was a cron job that SELECT’d pending alerts from the database every minute and called nodemailer.sendMail() in a for loop. It worked fine for the first 300 users. Then a payment failure wave triggered 14,000 notifications in one batch. The cron job opened 14,000 SMTP connections in 12 seconds, got rate-limited by the email provider, swallowed 9,000 silent failures, and the support team spent three days manually refunding customers whose cancellation emails never arrived.
That was when we learned that sending a notification and delivering a notification are two different things.
A notification engine has to handle at least five concerns that a naive loop does not:
- Channel routing (which users get email vs. push vs. in-app, and who decides)
- Template rendering (HTML email, push payload, and in-app toast from one logical event)
- Idempotency (if the worker crashes after sending but before marking delivered, you do not double-send)
- Rate limiting (every provider limits you, and hitting that limit silently loses messages)
- Delivery tracking (opens, clicks, bounces, and the state machine that retries or dead-letters)
This post builds a notification engine that handles all five, in about 200 lines of TypeScript, backed by a message queue and a database. You can adapt it to any stack.
The core data model
The notification engine centers on a single abstraction: the notification event. Everything else is derived from it.
interface NotificationEvent {
id: string; // uuidv7, sortable
userId: string;
type: string; // 'payment.success', 'team.invite', 'deploy.failed'
subject: string; // template key or literal string
data: Record<string, unknown>; // template variables
channels: Channel[]; // which channels to attempt
createdAt: Date;
}
type Channel = 'email' | 'push' | 'in-app';
The event is what your application code creates. It describes what happened, who should know, and where to tell them. It does not contain rendered content. Rendering happens later, in the delivery pipeline, so you can change templates without re-queuing events.
The delivery state lives in a separate table:
interface Delivery {
id: string;
eventId: string;
userId: string;
channel: Channel;
status: DeliveryStatus;
attemptCount: number;
lastAttemptAt?: Date;
error?: string;
deliveredAt?: Date;
createdAt: Date;
}
type DeliveryStatus =
| 'pending'
| 'delivering'
| 'delivered'
| 'bounced'
| 'rate-limited'
| 'dead-lettered';
Each event fans out into one Delivery row per requested channel. This lets you track email delivery separately from push delivery, retry email independently of push, and know exactly which channels failed when the user complains they did not get the alert.
CREATE TABLE notification_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
type TEXT NOT NULL,
subject TEXT NOT NULL,
data JSONB NOT NULL DEFAULT '{}',
channels TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE notification_deliveries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES notification_events(id),
user_id UUID NOT NULL,
channel TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempt_count INT NOT NULL DEFAULT 0,
last_attempt_at TIMESTAMPTZ,
error TEXT,
delivered_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_deliveries_pending
ON notification_deliveries (status, created_at)
WHERE status = 'pending';
CREATE INDEX idx_deliveries_event
ON notification_deliveries (event_id);
The partial index on status = 'pending' is critical. The delivery worker queries only pending rows, and the index stays small regardless of how many millions of delivered rows accumulate. Without it, the worker’s query degrades as the table grows, and your delivery throughput drops over time.
The delivery worker
The worker polls pending deliveries, renders templates, sends through the appropriate provider, and updates the delivery row. Here is the core loop:
import { Pool } from 'pg';
import { renderTemplate } from './templates';
import { sendEmail } from './providers/email';
import { sendPush } from './providers/push';
import { sendInApp } from './providers/in-app';
const db = new Pool({ connectionString: process.env.DATABASE_URL });
const CHANNEL_HANDLER: Record<Channel, Function> = {
email: sendEmail,
push: sendPush,
'in-app': sendInApp,
};
async function processDelivery(delivery: Delivery): Promise<void> {
// Fetch the event for template data
const event = await db.query(
'SELECT * FROM notification_events WHERE id = $1',
[delivery.eventId]
);
if (event.rows.length === 0) {
await deadLetter(delivery, 'event_not_found');
return;
}
const evt = event.rows[0];
// Render the channel-specific template
const content = renderTemplate(evt.subject, evt.data, delivery.channel);
if (!content) {
await deadLetter(delivery, 'template_render_failed');
return;
}
// Mark as delivering to prevent double-processing
await db.query(
`UPDATE notification_deliveries
SET status = 'delivering', attempt_count = attempt_count + 1, last_attempt_at = now()
WHERE id = $1 AND status = 'pending'`,
[delivery.id]
);
try {
const handler = CHANNEL_HANDLER[delivery.channel as Channel];
await handler({ userId: delivery.userId, content, eventId: delivery.eventId });
await db.query(
`UPDATE notification_deliveries
SET status = 'delivered', delivered_at = now()
WHERE id = $1`,
[delivery.id]
);
} catch (err) {
const isRateLimited = (err as Error).message.includes('rate_limit');
const isBounce = (err as Error).message.includes('bounce');
if (isRateLimited) {
await db.query(
`UPDATE notification_deliveries
SET status = 'rate-limited', error = $2
WHERE id = $1`,
[delivery.id, (err as Error).message]
);
} else if (isBounce) {
await db.query(
`UPDATE notification_deliveries
SET status = 'bounced', error = $2
WHERE id = $1`,
[delivery.id, (err as Error).message]
);
} else if ((delivery.attempt_count ?? 0) >= 3) {
await deadLetter(delivery, (err as Error).message);
} else {
// Reset to pending for next poll cycle
await db.query(
`UPDATE notification_deliveries
SET status = 'pending', error = $2
WHERE id = $1`,
[delivery.id, (err as Error).message]
);
}
}
}
async function deadLetter(delivery: Delivery, reason: string) {
await db.query(
`UPDATE notification_deliveries
SET status = 'dead-lettered', error = $2
WHERE id = $1`,
[delivery.id, reason]
);
}
This loop runs in a worker process. It queries the partial index, processes one batch, waits a beat, and repeats. The key decisions are in the error handling: rate-limited rows stay rate-limited until a separate recovery job retries them after the provider’s rate-limit window expires. Bounced rows dead-letter immediately and never retry. Transient errors retry up to three times before dead-lettering.
Idempotency: the quiet killer
The most subtle bug in a notification worker is double-sending. Here is how it happens:
- Worker picks up a pending delivery.
- Worker sends the email through SendGrid. SendGrid accepts it (200 OK).
- Worker calls
UPDATE ... SET status = 'delivered'but the database connection drops before the UPDATE commits. - Worker is marked as crashed. A replacement worker picks up the same delivery.
- SendGrid gets the same email again. User gets two identical notifications.
The fix is an idempotency key scoped to the delivery, set before the send and checked after recovery:
// In sendEmail() provider:
async function sendEmail({ userId, content, eventId, deliveryId }: {
userId: string;
content: EmailContent;
eventId: string;
deliveryId: string;
}) {
const idempotencyKey = `delivery:${deliveryId}`;
// Provider-level idempotency key (supported by SendGrid, SES, Mailgun)
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.SENDGRID_API_KEY}`,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
personalizations: [{ to: [{ email: getUserEmail(userId) }] }],
subject: content.subject,
content: [{ type: 'text/html', value: content.html }],
}),
});
if (response.status === 422 && (await response.json()).idempotency === 'already_sent') {
// Provider already processed this deliveryId. Mark delivered and move on.
return;
}
if (!response.ok) {
throw new Error(await parseProviderError(response));
}
}
If your email provider does not support idempotency keys (some cheaper ones do not), you can build a separate delivery_locks table:
CREATE TABLE delivery_locks (
delivery_id UUID PRIMARY KEY,
locked_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Before sending, try to insert the lock
INSERT INTO delivery_locks (delivery_id)
VALUES ($1)
ON CONFLICT (delivery_id) DO NOTHING
RETURNING delivery_id;
If the INSERT returns a row, you won the send slot. If it returns nothing, another worker is already handling this delivery. This is a distributed lock, not perfect, but a 10-second TTL on the lock row is good enough to prevent double-sends in practice.
Template rendering per channel
A notification event with type 'deploy.failed' needs to render differently for each channel:
- Email: Rich HTML with deployment logs, a CTA button to roll back, and footer with unsubscribe link.
- Push: Short title + body, max 200 characters. “Deploy failed: The production API deployment rolled back after 3 failed health checks.”
- In-app: A toast or badge that appears in the navigation bar. “Deploy failed - check the activity log.”
The template system maps {type, channel} to a render function:
import { compile } from 'handlebars'; // or any template engine
const templateRegistry = new Map<string, HandlebarsTemplateFunction>();
function registerTemplate(type: string, channel: Channel, template: string) {
templateRegistry.set(`${type}:${channel}`, compile(template));
}
function renderTemplate(type: string, data: Record<string, unknown>, channel: Channel): Content | null {
const key = `${type}:${channel}`;
const tmpl = templateRegistry.get(key);
if (!tmpl) {
// Fall back to a generic template
const generic = templateRegistry.get(`__generic:${channel}`);
if (!generic) return null;
return generic(data);
}
return tmpl(data);
}
// Register templates
registerTemplate('deploy.failed', 'email',
`<h1>Deploy Failed</h1>
<p>{{serviceName}} failed to deploy {{commitSha}}.</p>
<p>Error: {{error}}</p>
<a href="{{rollbackUrl}}">Rollback</a>`
);
registerTemplate('deploy.failed', 'push',
`{{serviceName}} deploy failed: {{error}}. Tap to rollback.`
);
registerTemplate('deploy.failed', 'in-app',
`Deploy failed - {{serviceName}}: {{error}}`
);
Templates are functions, not files fetched at runtime. This keeps rendering sub-millisecond and makes templates testable in unit tests. If you need administrator-editable templates, store compiled functions in Redis with a TTL and recompile on write.
Rate limiting per provider
Every notification provider has limits. SendGrid caps email sends at 100/sec per IP on a free plan. Firebase Cloud Messaging recommends batching push notifications. Exceeding these limits does not just fail the request. It can get your account suspended.
The rate limiter sits between the worker and the provider call:
import { TokenBucket } from './rate-limiter';
// One bucket per channel
const rateLimiters = {
email: new TokenBucket({ capacity: 100, refillRate: 100, intervalMs: 1000 }),
push: new TokenBucket({ capacity: 500, refillRate: 500, intervalMs: 1000 }),
'in-app': new TokenBucket({ capacity: 5000, refillRate: 5000, intervalMs: 1000 }),
};
async function processDeliveryWithRateLimit(delivery: Delivery): Promise<void> {
const limiter = rateLimiters[delivery.channel as Channel];
const allowed = await limiter.consume(1);
if (!allowed) {
// Re-queue with a delay instead of burning CPU polling
await db.query(
`UPDATE notification_deliveries
SET status = 'rate-limited',
error = 'rate_limit_exceeded',
last_attempt_at = now()
WHERE id = $1`,
[delivery.id]
);
return;
}
await processDelivery(delivery);
}
A token bucket is a good fit here: it allows bursts up to the capacity while enforcing a steady refill rate. The in-app channel gets a much higher limit because it is a database write, not an API call to a third party.
A separate recovery job scans for rate-limited deliveries older than one second and resets them to pending:
async function recoverRateLimited(): Promise<void> {
await db.query(
`UPDATE notification_deliveries
SET status = 'pending', error = NULL
WHERE status = 'rate-limited'
AND last_attempt_at < now() - interval '1 second'`
);
}
Run this job every second on a timer. It acts as a dead-simple backpressure valve: when the provider is overwhelmed, delivery stalls gracefully and recovers without a crash.
User channel preferences
Not every user wants email notifications at 3 a.m. when a CI build passes. The notification engine needs to respect user preferences at fan-out time, not at delivery time.
When the application creates a notification event, it specifies all channels it considers relevant. The fan-out step intersects those against the user’s preferences:
async function fanOutEvent(event: NotificationEvent): Promise<void> {
// Fetch user's notification preferences
const prefs = await db.query(
`SELECT channel, enabled FROM user_notification_prefs
WHERE user_id = $1 AND type = $2`,
[event.userId, event.type]
);
// Build a set of enabled channels for this event type
const enabledChannels = new Set(
prefs.rows
.filter((p: any) => p.enabled)
.map((p: any) => p.channel)
);
// Intersect event.channels with enabled channels
const targetChannels = event.channels.filter((ch) => enabledChannels.has(ch));
if (targetChannels.length === 0) {
// User does not want to be notified about this event type.
// Log or no-op. Do not create delivery rows.
return;
}
// Insert one delivery row per target channel
const values = targetChannels.map((channel) => {
const id = crypto.randomUUID();
return `('${id}', '${event.id}', '${event.userId}', '${channel}', 'pending', now())`;
});
await db.query(
`INSERT INTO notification_deliveries (id, event_id, user_id, channel, status, created_at)
VALUES ${values.join(', ')}`
);
}
This approach avoids ever inserting a delivery row for a channel the user has disabled. That matters for billing (you pay per email sent), for reputation (high bounce rates get you banned), and for user trust (spamming the wrong channel is how you lose the user).
The worker loop with backpressure
The final piece is the worker’s main loop. It needs to batch queries, respect rate limits across the whole system, and stop fetching when the database or providers cannot keep up:
const BATCH_SIZE = 50;
const POLL_INTERVAL_MS = 200;
async function mainWorkerLoop(): Promise<void> {
const inflight = new Set<string>();
while (true) {
const available = BATCH_SIZE - inflight.size;
if (available <= 0) {
await sleep(100);
continue;
}
const result = await db.query(
`SELECT d.*, e.data, e.subject
FROM notification_deliveries d
JOIN notification_events e ON e.id = d.event_id
WHERE d.status = 'pending'
ORDER BY d.created_at ASC
LIMIT $1
FOR UPDATE SKIP LOCKED`,
[available]
);
for (const row of result.rows) {
inflight.add(row.id);
// Process without awaiting; let concurrency build up
processDeliveryWithRateLimit(row)
.catch((err) => console.error('Delivery failed:', err))
.finally(() => inflight.delete(row.id));
}
if (result.rows.length < available) {
await sleep(POLL_INTERVAL_MS);
}
}
}
FOR UPDATE SKIP LOCKED is the key Postgres feature here. It locks the rows being processed so no other worker picks them up, but it skips rows that another worker has already locked. This lets you safely scale the worker horizontally to multiple processes without coordination.
The inflight set acts as a local backpressure throttle. When the worker has 50 deliveries in flight, it stops fetching new ones until some complete. This prevents unbounded queue growth in memory.
The practical takeaway
A notification engine is not hard to build. The hard part is handling the edge cases that only show up under load: rate limits, partial failures, double-sends, and the user who unsubscribed from email but still gets push. The data model and worker loop above handle all of them.
Before you ship your notification engine, verify these behaviors in production:
- If the email provider returns a 429, the delivery enters
rate-limitedstate and is retried after one second (not dropped). - If the worker crashes between sending and marking delivered, the idempotency key prevents the user from getting a duplicate.
- If a user disables push notifications, the delivery row is never inserted (the engine does not waste time or money on unrequested sends).
- If the database is under load,
SKIP LOCKEDprevents worker contention and keeps delivery latency consistent. - If a template is missing for a specific
{type, channel}pair, the generic fallback renders instead of throwing a 500.
Your notification system will fail. The question is whether it fails gracefully with a clear error in the delivery table and a recovery path, or silently with an empty promise to a user who never gets the message.
A note from Yojji
Building a notification engine that handles channel routing, idempotency, and provider rate limiting at production scale requires the kind of infrastructure thinking that most teams discover only after the first silent delivery failure. The patterns in this post (partial indexes for worker queries, token bucket rate limiters, idempotency keys for provider calls) are the building blocks Yojji’s engineering teams use every day when designing scalable backend services for their clients.
Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product delivery from discovery through DevOps and production operations. If your notification pipeline is held together by cron jobs and prayer, Yojji’s teams have built this pattern at scale and can help ship a production-grade replacement.