Build a multi-channel notification system in Replit in 2-4 hours. Use Replit Agent to generate an Express + PostgreSQL app that dispatches in-app, email (SendGrid), and SMS (Twilio) notifications from a centralized event-driven queue with user preferences and retry logic. Deploy on Reserved VM.
What you're building
A notification system is the infrastructure that keeps users informed about what matters in your app. When an order ships, a payment succeeds, or a teammate mentions someone, the right person should get the right notification through the right channel. Without a centralized system, notification logic gets duplicated across every feature, leading to inconsistent behavior and broken alerts.
Replit Agent generates the full notification infrastructure in one prompt. The architecture is event-driven: your application code calls POST /api/notifications/dispatch with an event type and user ID. The dispatch engine looks up the template for that event, checks the user's channel preferences, and creates notification records for each enabled channel. A queue processor then picks up pending notifications and delivers them via SendGrid (email) or Twilio (SMS).
The most important design decision is the atomic queue claim. Without it, if two queue processor runs overlap (which can happen with setInterval), the same SMS or email gets sent twice. The processor uses UPDATE ... WHERE status = 'pending' AND next_retry_at <= now() RETURNING * to atomically claim a batch of notifications, ensuring each notification is delivered exactly once. Store API keys in Replit Secrets and deploy on Reserved VM so the queue processor runs continuously.
Final result
A fully functional multi-channel notification system with event templates, user preference management, in-app notifications with SSE unread counts, email via SendGrid, SMS via Twilio, and a retry queue with exponential backoff — deployed on Replit Reserved VM.
Tech stack
Prerequisites
- A Replit Core account or higher (Reserved VM required for continuous queue processing)
- A SendGrid account with an API key (free tier: 100 emails/day)
- A Twilio account with a phone number and API credentials (free trial available)
- A list of event types your app needs to send notifications for (order_shipped, payment_received, etc.)
Build steps
Scaffold the project with Replit Agent
Create a new Repl and use the Agent prompt below to generate the full notification system with Drizzle schema, dispatch engine, queue processor, and React preferences UI.
1// Type this into Replit Agent:2// Build a multi-channel notification system with Express and PostgreSQL using Drizzle ORM.3// Tables:4// - notifications: id serial pk, user_id text not null, channel text not null5// (enum: in_app/email/sms), title text not null, body text not null,6// type text not null (enum: info/warning/error/success), reference_type text,7// reference_id text, status text default 'pending'8// (enum: pending/sent/delivered/failed/read), read_at timestamp,9// sent_at timestamp, created_at timestamp default now()10// - notification_preferences: id serial pk, user_id text not null, channel text not null,11// event_type text not null, enabled boolean default true,12// unique(user_id, channel, event_type)13// - notification_templates: id serial pk, event_type text unique not null,14// title_template text not null, body_template text not null, channels text[] not null15// - notification_queue: id serial pk, notification_id integer FK notifications,16// channel text not null, payload jsonb not null, attempts integer default 0,17// max_attempts integer default 3, next_retry_at timestamp default now(),18// error_message text, created_at timestamp default now()19// Routes: GET /api/notifications (user's in-app with pagination), PATCH /api/notifications/:id/read,20// PATCH /api/notifications/read-all, GET /api/notifications/unread-count,21// GET /api/notifications/preferences, PUT /api/notifications/preferences,22// POST /api/notifications/dispatch (internal fan-out engine),23// GET /api/notifications/stream (SSE for real-time in-app delivery).24// React frontend: bell icon with unread badge, dropdown panel, preferences settings page.25// Use Replit Auth. Bind server to 0.0.0.0.Pro tip: After Agent creates the schema, immediately add your notification_templates rows using Drizzle Studio. At minimum, create one template for each event type your app uses (e.g., order_shipped, payment_received, new_message).
Expected result: A running Express app with all four tables. Drizzle Studio shows the schema. The React frontend has a bell icon in the header that will show notification counts.
Build the dispatch engine
The dispatch route is the single entry point for all notifications. Given an event_type, user_id, and variable map, it loads the template, checks preferences, interpolates variables, and creates notification + queue records for each enabled channel.
1const express = require('express');2const { db } = require('../db');3const { notifications, notificationPreferences, notificationTemplates, notificationQueue } = require('../../shared/schema');4const { eq, and } = require('drizzle-orm');56const router = express.Router();78// Simple template variable interpolation: {{variable}} -> value9function interpolate(template, variables) {10 return template.replace(/{{(\w+)}}/g, (_, key) => variables[key] || '');11}1213// POST /api/notifications/dispatch — fan-out engine14// Body: { eventType, userId, variables: { orderId: '123', amount: '$99' } }15router.post('/dispatch', async (req, res) => {16 const { eventType, userId, variables = {} } = req.body;17 if (!eventType || !userId) {18 return res.status(400).json({ error: 'eventType and userId required' });19 }2021 // Load template for this event22 const [template] = await db.select().from(notificationTemplates)23 .where(eq(notificationTemplates.eventType, eventType));24 if (!template) {25 return res.status(404).json({ error: `No template for event type: ${eventType}` });26 }2728 const title = interpolate(template.titleTemplate, variables);29 const body = interpolate(template.bodyTemplate, variables);3031 const createdNotifications = [];3233 // Fan out to each channel this template supports34 for (const channel of template.channels) {35 // Check user preference for this channel + event type36 const [pref] = await db.select().from(notificationPreferences)37 .where(and(38 eq(notificationPreferences.userId, userId),39 eq(notificationPreferences.channel, channel),40 eq(notificationPreferences.eventType, eventType)41 ));4243 // Default to enabled if no preference record exists44 if (pref && !pref.enabled) continue;4546 const [notification] = await db.insert(notifications).values({47 userId,48 channel,49 title,50 body,51 type: variables.notificationType || 'info',52 referenceType: variables.referenceType || null,53 referenceId: variables.referenceId || null,54 }).returning();5556 // Queue email and SMS for async delivery (in_app is real-time via SSE)57 if (channel !== 'in_app') {58 await db.insert(notificationQueue).values({59 notificationId: notification.id,60 channel,61 payload: { userId, title, body, ...variables },62 });63 }6465 createdNotifications.push(notification);66 }6768 res.status(201).json({ dispatched: createdNotifications.length, notifications: createdNotifications });69});7071module.exports = router;Expected result: POST /api/notifications/dispatch with eventType: 'order_shipped', userId: 'u_123', variables: { orderId: '456', courier: 'FedEx' } creates one notification per enabled channel and queues email/SMS for delivery.
Build the queue processor with atomic claim
The queue processor claims a batch of pending notifications atomically, then delivers them via SendGrid or Twilio. Failed deliveries use exponential backoff. Run this on Reserved VM with setInterval.
1const axios = require('axios');2const sgMail = require('@sendgrid/mail');3const twilio = require('twilio');4const { db } = require('../db');5const { notificationQueue, notifications } = require('../../shared/schema');6const { eq, sql } = require('drizzle-orm');78sgMail.setApiKey(process.env.SENDGRID_API_KEY);9const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);1011async function processQueue() {12 // Atomic claim: mark as 'processing' to prevent duplicates13 const claimed = await db.execute(sql`14 UPDATE notification_queue15 SET error_message = 'processing'16 WHERE id IN (17 SELECT id FROM notification_queue18 WHERE error_message IS DISTINCT FROM 'processing'19 AND attempts < max_attempts20 AND next_retry_at <= NOW()21 LIMIT 1022 FOR UPDATE SKIP LOCKED23 )24 RETURNING *25 `);2627 for (const job of claimed.rows) {28 const { id, channel, payload, attempts } = job;29 let success = false;30 let errorMsg = null;3132 try {33 if (channel === 'email' && payload.userEmail) {34 await sgMail.send({35 to: payload.userEmail,36 from: process.env.FROM_EMAIL || 'noreply@yourdomain.com',37 subject: payload.title,38 text: payload.body,39 });40 success = true;41 } else if (channel === 'sms' && payload.userPhone) {42 await twilioClient.messages.create({43 body: `${payload.title}: ${payload.body}`,44 from: process.env.TWILIO_PHONE_NUMBER,45 to: payload.userPhone,46 });47 success = true;48 }49 } catch (err) {50 errorMsg = err.message;51 }5253 if (success) {54 // Mark delivered55 await db.execute(sql`DELETE FROM notification_queue WHERE id = ${id}`);56 await db.execute(sql`UPDATE notifications SET status = 'sent', sent_at = NOW() WHERE id = ${job.notification_id}`);57 } else {58 // Exponential backoff: 1min, 2min, 4min59 const nextRetrySeconds = Math.pow(2, attempts) * 60;60 await db.execute(sql`61 UPDATE notification_queue62 SET attempts = attempts + 1,63 next_retry_at = NOW() + INTERVAL '${nextRetrySeconds} seconds',64 error_message = ${errorMsg || 'Unknown error'}65 WHERE id = ${id}66 `);6768 if (attempts + 1 >= job.max_attempts) {69 await db.execute(sql`UPDATE notifications SET status = 'failed' WHERE id = ${job.notification_id}`);70 }71 }72 }73}7475// Run processor every 30 seconds on Reserved VM76setInterval(processQueue, 30000);77processQueue(); // Run once on startup7879module.exports = { processQueue };Pro tip: Add SENDGRID_API_KEY, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, and FROM_EMAIL to Replit Secrets (lock icon in sidebar). The processor silently skips channels where the API key is missing — useful during development when you only want in-app notifications.
Add the SSE stream for real-time in-app notifications
The SSE endpoint delivers in-app notifications to the browser instantly. When dispatch creates an in_app notification, it immediately writes the SSE event. This gives users a real-time bell notification without polling.
1// In-memory SSE subscriber map: userId -> res2const inAppSubscribers = new Map();34// GET /api/notifications/stream — SSE for in-app delivery5router.get('/stream', (req, res) => {6 const userId = req.user?.id;7 if (!userId) return res.status(401).end();89 res.writeHead(200, {10 'Content-Type': 'text/event-stream',11 'Cache-Control': 'no-cache',12 'Connection': 'keep-alive',13 'X-Accel-Buffering': 'no',14 });15 res.write(`data: {"type":"connected"}\n\n`);1617 const pingInterval = setInterval(() => {18 try { res.write(': ping\n\n'); } catch (e) { clearInterval(pingInterval); }19 }, 30000);2021 inAppSubscribers.set(userId, res);2223 req.on('close', () => {24 clearInterval(pingInterval);25 inAppSubscribers.delete(userId);26 });27});2829// Helper: push in-app notification to connected user30exports.pushInApp = (userId, notification) => {31 const res = inAppSubscribers.get(userId);32 if (res) {33 try {34 res.write(`data: ${JSON.stringify(notification)}\n\n`);35 } catch (e) {36 inAppSubscribers.delete(userId);37 }38 }39};4041// Call pushInApp from the dispatch route after creating in_app notification:42// const { pushInApp } = require('./stream');43// if (channel === 'in_app') pushInApp(userId, notification);Expected result: The browser's notification bell receives a real-time event when dispatch creates an in_app notification for the logged-in user. The unread count badge increments immediately without a page refresh.
Add read tracking, unread count, and deploy on Reserved VM
The unread count endpoint powers the bell badge. PATCH read and read-all keep the badge accurate. Deploy on Reserved VM so the queue processor runs continuously and SSE connections stay alive.
1// GET /api/notifications/unread-count2router.get('/unread-count', async (req, res) => {3 const userId = req.user?.id;4 if (!userId) return res.status(401).json({ error: 'Login required' });56 const result = await db.execute(sql`7 SELECT COUNT(*) as count FROM notifications8 WHERE user_id = ${userId}9 AND channel = 'in_app'10 AND status != 'read'11 `);12 res.json({ count: parseInt(result.rows[0]?.count || 0) });13});1415// PATCH /api/notifications/:id/read16router.patch('/:id/read', async (req, res) => {17 const userId = req.user?.id;18 await db.update(notifications)19 .set({ status: 'read', readAt: new Date() })20 .where(and(21 eq(notifications.id, parseInt(req.params.id)),22 eq(notifications.userId, userId)23 ));24 res.json({ ok: true });25});2627// PATCH /api/notifications/read-all28router.patch('/read-all', async (req, res) => {29 const userId = req.user?.id;30 await db.execute(sql`31 UPDATE notifications32 SET status = 'read', read_at = NOW()33 WHERE user_id = ${userId} AND channel = 'in_app' AND status != 'read'34 `);35 res.json({ ok: true });36});3738// server/index.js — require the queue processor to start on Reserved VM39const { processQueue } = require('./queue/processor');40// processQueue() is called inside processor.js via setInterval + immediate call41console.log('Notification queue processor started');Pro tip: Deploy on Reserved VM. The queue processor uses setInterval — it only runs continuously when the Node.js process is always on. On Autoscale, the process sleeps between requests and setInterval stops, causing missed email/SMS deliveries.
Expected result: GET /api/notifications/unread-count returns the current unread count. The queue processor logs to the console every 30 seconds. Test by dispatching a notification and watching the email arrive via SendGrid.
Complete code
1const sgMail = require('@sendgrid/mail');2const twilio = require('twilio');3const { db } = require('../db');4const { sql } = require('drizzle-orm');56if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);7const twilioClient = process.env.TWILIO_ACCOUNT_SID8 ? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)9 : null;1011async function processQueue() {12 const claimed = await db.execute(sql`13 UPDATE notification_queue14 SET error_message = 'processing'15 WHERE id IN (16 SELECT id FROM notification_queue17 WHERE (error_message IS NULL OR error_message != 'processing')18 AND attempts < max_attempts19 AND next_retry_at <= NOW()20 LIMIT 1021 FOR UPDATE SKIP LOCKED22 )23 RETURNING *24 `);2526 for (const job of claimed.rows) {27 const { id, channel, payload, attempts, notification_id } = job;28 let success = false;29 let errorMsg = null;3031 try {32 if (channel === 'email' && payload.userEmail && process.env.SENDGRID_API_KEY) {33 await sgMail.send({34 to: payload.userEmail,35 from: process.env.FROM_EMAIL,36 subject: payload.title,37 text: payload.body,38 html: `<p>${payload.body}</p>`,39 });40 success = true;41 } else if (channel === 'sms' && payload.userPhone && twilioClient) {42 await twilioClient.messages.create({43 body: `${payload.title}: ${payload.body}`,44 from: process.env.TWILIO_PHONE_NUMBER,45 to: payload.userPhone,46 });47 success = true;48 } else {49 // Missing config — mark as failed without retry50 errorMsg = `Missing config for channel: ${channel}`;51 }52 } catch (err) {53 errorMsg = err.message;54 }5556 if (success) {57 await db.execute(sql`DELETE FROM notification_queue WHERE id = ${id}`);58 await db.execute(sql`UPDATE notifications SET status = 'sent', sent_at = NOW() WHERE id = ${notification_id}`);59 } else {60 const nextRetry = Math.pow(2, attempts) * 60;Customization ideas
Push notifications via Web Push API
Add a push_subscriptions table storing browser push subscription objects. On dispatch, use the web-push npm package to send browser push notifications to users who have enabled them. Store VAPID keys in Replit Secrets.
Notification digest
Add a daily digest mode to notification_preferences. Instead of sending individual emails, collect all notifications from the past 24 hours and send one digest email. A Scheduled Deployment runs at 8am and sends digest emails to opted-in users.
Notification center page
Add a full /notifications page showing all past notifications with infinite scroll, type-color indicators, and click-to-navigate to the referenced object (e.g., clicking an order notification opens the order detail page).
Admin broadcast
Add a POST /api/admin/notify/broadcast route that dispatches a notification to all users or a filtered segment. Useful for system announcements, maintenance windows, or new feature announcements.
Common pitfalls
Pitfall: Not using SKIP LOCKED in the queue processor
How to avoid: Use SELECT ... FOR UPDATE SKIP LOCKED in the claim query. PostgreSQL skips rows that are locked by another transaction, ensuring each job is claimed by exactly one processor run.
Pitfall: Deploying on Autoscale instead of Reserved VM
How to avoid: Deploy on Reserved VM. The process runs continuously, and setInterval fires every 30 seconds as expected.
Pitfall: Storing user email and phone number in the notification_queue payload
How to avoid: Store only user_id in the queue payload. Fetch the current email/phone from the users table at delivery time. This ensures deliveries always go to the most current contact information.
Pitfall: Not checking user preferences before creating notifications
How to avoid: The dispatch engine checks notification_preferences for every channel before creating a notification row. If no preference record exists, default to enabled (users must actively opt out).
Best practices
- Store SENDGRID_API_KEY, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER, and FROM_EMAIL in Replit Secrets (lock icon) — never hardcode them.
- Use SELECT FOR UPDATE SKIP LOCKED in the queue processor to prevent duplicate delivery in concurrent environments.
- Deploy on Reserved VM so the setInterval queue processor runs continuously without interruption.
- Default user preferences to enabled — require opt-out rather than opt-in. Most users want notifications; a small percentage actively unsubscribe.
- Add your event type templates to notification_templates using Drizzle Studio before testing dispatch. The dispatch engine fails silently if no template exists.
- Test with a single channel first (in_app only, no email/SMS). Add email and SMS after the in-app flow works end-to-end.
- Handle missing API keys gracefully in the queue processor — mark jobs with missing config as failed rather than retrying infinitely.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a multi-channel notification system with Express and PostgreSQL. I have a notification_queue table with columns: id, channel (enum: email/sms), payload (jsonb), attempts (integer), max_attempts (integer default 3), next_retry_at (timestamp). Help me write a Node.js queue processor function that: (1) atomically claims up to 10 pending jobs using SELECT ... FOR UPDATE SKIP LOCKED, (2) calls SendGrid for email jobs and Twilio for SMS jobs, (3) deletes successful jobs from the queue, and (4) updates failed jobs with incremented attempts and exponential backoff on next_retry_at.
Add a notification analytics dashboard to the notification system. Create a GET /api/admin/notifications/stats route that returns: total dispatched per event type (last 30 days), delivery success rate per channel (email_sent / email_created), average delivery latency (sent_at - created_at), and the top 10 users by notification volume. Build a React admin page with four stat cards and a table showing event type breakdown. Use recharts for a 30-day delivery volume bar chart.
Frequently asked questions
What's the difference between this and email automation?
A notification system dispatches transactional alerts triggered by application events (order_shipped, payment_received, new_mention). Email automation builds scheduled drip campaigns targeting marketing contacts based on time or behavior sequences. Notifications are reactive; email automation is proactive.
What Replit plan do I need?
A paid plan (Core or higher) is required for Reserved VM deployment. The queue processor uses setInterval — this only runs continuously on Reserved VM. Autoscale puts the instance to sleep between requests, stopping the processor and causing delayed or missed deliveries.
How do I get a SendGrid API key?
Sign up at sendgrid.com (free tier: 100 emails/day). Go to Settings → API Keys → Create API Key. Select 'Mail Send' permissions. Copy the key and add it to Replit Secrets as SENDGRID_API_KEY. Also verify a sender email address in SendGrid Settings → Sender Authentication.
How do I get Twilio credentials?
Sign up at twilio.com (free trial with $15 credit). From the Console Dashboard, copy Account SID and Auth Token. Go to Phone Numbers → Buy a number. Add TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER to Replit Secrets.
What happens if a notification can't be delivered after 3 attempts?
After max_attempts (default 3) failures, the queue processor sets the notification status to 'failed' in the notifications table. The job remains in notification_queue with the error_message from the last failure. You can query failed jobs in Drizzle Studio and manually retry by resetting attempts to 0.
How do I add a new event type?
Insert a row into notification_templates with the new event_type, title_template (with {{variable}} placeholders), body_template, and the channels array (e.g., ['in_app', 'email']). That's all — the dispatch engine automatically uses the template when you call POST /api/notifications/dispatch with that event_type.
Can RapidDev help build a custom notification system?
Yes. RapidDev has built 600+ apps including multi-channel communication infrastructure. They can add push notifications, WhatsApp delivery, custom digest schedules, and integration with your existing event sources. Book a free consultation at rapidevelopers.com.
Why use SKIP LOCKED in the queue processor?
SKIP LOCKED tells PostgreSQL to skip rows that are currently locked by another transaction. If two queue processor runs overlap, the second run skips rows being processed by the first — preventing the same email or SMS from being sent twice.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation