Skip to main content
RapidDev - Software Development Agency

How to Build a Notification System with Replit

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'll build

  • Centralized dispatch engine that fans out events to in-app, email, and SMS channels based on user preferences
  • Notification templates with Handlebars-style variable interpolation for each event type
  • User preference matrix: per-user per-channel per-event-type opt-in/opt-out toggles
  • Retry queue with exponential backoff for failed email and SMS deliveries
  • In-app notification bell with real-time unread count via Server-Sent Events
  • Atomic queue claim to prevent duplicate sends in concurrent environments
  • SendGrid email delivery and Twilio SMS delivery with API keys from Replit Secrets
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced14 min read2-4 hoursReplit Core or higherApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
SendGridEmail Delivery
TwilioSMS Delivery
Replit AuthAuth

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

1

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.

prompt.txt
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 null
5// (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 null
15// - 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.

2

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.

server/routes/dispatch.js
1const express = require('express');
2const { db } = require('../db');
3const { notifications, notificationPreferences, notificationTemplates, notificationQueue } = require('../../shared/schema');
4const { eq, and } = require('drizzle-orm');
5
6const router = express.Router();
7
8// Simple template variable interpolation: {{variable}} -> value
9function interpolate(template, variables) {
10 return template.replace(/{{(\w+)}}/g, (_, key) => variables[key] || '');
11}
12
13// POST /api/notifications/dispatch — fan-out engine
14// 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 }
20
21 // Load template for this event
22 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 }
27
28 const title = interpolate(template.titleTemplate, variables);
29 const body = interpolate(template.bodyTemplate, variables);
30
31 const createdNotifications = [];
32
33 // Fan out to each channel this template supports
34 for (const channel of template.channels) {
35 // Check user preference for this channel + event type
36 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 ));
42
43 // Default to enabled if no preference record exists
44 if (pref && !pref.enabled) continue;
45
46 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();
55
56 // 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 }
64
65 createdNotifications.push(notification);
66 }
67
68 res.status(201).json({ dispatched: createdNotifications.length, notifications: createdNotifications });
69});
70
71module.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.

3

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.

server/queue/processor.js
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');
7
8sgMail.setApiKey(process.env.SENDGRID_API_KEY);
9const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);
10
11async function processQueue() {
12 // Atomic claim: mark as 'processing' to prevent duplicates
13 const claimed = await db.execute(sql`
14 UPDATE notification_queue
15 SET error_message = 'processing'
16 WHERE id IN (
17 SELECT id FROM notification_queue
18 WHERE error_message IS DISTINCT FROM 'processing'
19 AND attempts < max_attempts
20 AND next_retry_at <= NOW()
21 LIMIT 10
22 FOR UPDATE SKIP LOCKED
23 )
24 RETURNING *
25 `);
26
27 for (const job of claimed.rows) {
28 const { id, channel, payload, attempts } = job;
29 let success = false;
30 let errorMsg = null;
31
32 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 }
52
53 if (success) {
54 // Mark delivered
55 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, 4min
59 const nextRetrySeconds = Math.pow(2, attempts) * 60;
60 await db.execute(sql`
61 UPDATE notification_queue
62 SET attempts = attempts + 1,
63 next_retry_at = NOW() + INTERVAL '${nextRetrySeconds} seconds',
64 error_message = ${errorMsg || 'Unknown error'}
65 WHERE id = ${id}
66 `);
67
68 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}
74
75// Run processor every 30 seconds on Reserved VM
76setInterval(processQueue, 30000);
77processQueue(); // Run once on startup
78
79module.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.

4

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.

server/routes/stream.js
1// In-memory SSE subscriber map: userId -> res
2const inAppSubscribers = new Map();
3
4// GET /api/notifications/stream — SSE for in-app delivery
5router.get('/stream', (req, res) => {
6 const userId = req.user?.id;
7 if (!userId) return res.status(401).end();
8
9 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`);
16
17 const pingInterval = setInterval(() => {
18 try { res.write(': ping\n\n'); } catch (e) { clearInterval(pingInterval); }
19 }, 30000);
20
21 inAppSubscribers.set(userId, res);
22
23 req.on('close', () => {
24 clearInterval(pingInterval);
25 inAppSubscribers.delete(userId);
26 });
27});
28
29// Helper: push in-app notification to connected user
30exports.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};
40
41// 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.

5

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.

server/routes/notifications.js
1// GET /api/notifications/unread-count
2router.get('/unread-count', async (req, res) => {
3 const userId = req.user?.id;
4 if (!userId) return res.status(401).json({ error: 'Login required' });
5
6 const result = await db.execute(sql`
7 SELECT COUNT(*) as count FROM notifications
8 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});
14
15// PATCH /api/notifications/:id/read
16router.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});
26
27// PATCH /api/notifications/read-all
28router.patch('/read-all', async (req, res) => {
29 const userId = req.user?.id;
30 await db.execute(sql`
31 UPDATE notifications
32 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});
37
38// server/index.js — require the queue processor to start on Reserved VM
39const { processQueue } = require('./queue/processor');
40// processQueue() is called inside processor.js via setInterval + immediate call
41console.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

server/queue/processor.js
1const sgMail = require('@sendgrid/mail');
2const twilio = require('twilio');
3const { db } = require('../db');
4const { sql } = require('drizzle-orm');
5
6if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);
7const twilioClient = process.env.TWILIO_ACCOUNT_SID
8 ? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
9 : null;
10
11async function processQueue() {
12 const claimed = await db.execute(sql`
13 UPDATE notification_queue
14 SET error_message = 'processing'
15 WHERE id IN (
16 SELECT id FROM notification_queue
17 WHERE (error_message IS NULL OR error_message != 'processing')
18 AND attempts < max_attempts
19 AND next_retry_at <= NOW()
20 LIMIT 10
21 FOR UPDATE SKIP LOCKED
22 )
23 RETURNING *
24 `);
25
26 for (const job of claimed.rows) {
27 const { id, channel, payload, attempts, notification_id } = job;
28 let success = false;
29 let errorMsg = null;
30
31 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 retry
50 errorMsg = `Missing config for channel: ${channel}`;
51 }
52 } catch (err) {
53 errorMsg = err.message;
54 }
55
56 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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.