# How to Build a Notification System with Replit

- Tool: How to Build with Replit
- Difficulty: Advanced
- Compatibility: Replit Core or higher
- Last updated: April 2026

## 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.

## Before you start

- 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.)

## Step-by-step guide

### 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.

```
// Type this into Replit Agent:
// Build a multi-channel notification system with Express and PostgreSQL using Drizzle ORM.
// Tables:
// - notifications: id serial pk, user_id text not null, channel text not null
//   (enum: in_app/email/sms), title text not null, body text not null,
//   type text not null (enum: info/warning/error/success), reference_type text,
//   reference_id text, status text default 'pending'
//   (enum: pending/sent/delivered/failed/read), read_at timestamp,
//   sent_at timestamp, created_at timestamp default now()
// - notification_preferences: id serial pk, user_id text not null, channel text not null,
//   event_type text not null, enabled boolean default true,
//   unique(user_id, channel, event_type)
// - notification_templates: id serial pk, event_type text unique not null,
//   title_template text not null, body_template text not null, channels text[] not null
// - notification_queue: id serial pk, notification_id integer FK notifications,
//   channel text not null, payload jsonb not null, attempts integer default 0,
//   max_attempts integer default 3, next_retry_at timestamp default now(),
//   error_message text, created_at timestamp default now()
// Routes: GET /api/notifications (user's in-app with pagination), PATCH /api/notifications/:id/read,
// PATCH /api/notifications/read-all, GET /api/notifications/unread-count,
// GET /api/notifications/preferences, PUT /api/notifications/preferences,
// POST /api/notifications/dispatch (internal fan-out engine),
// GET /api/notifications/stream (SSE for real-time in-app delivery).
// React frontend: bell icon with unread badge, dropdown panel, preferences settings page.
// 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.

```
const express = require('express');
const { db } = require('../db');
const { notifications, notificationPreferences, notificationTemplates, notificationQueue } = require('../../shared/schema');
const { eq, and } = require('drizzle-orm');

const router = express.Router();

// Simple template variable interpolation: {{variable}} -> value
function interpolate(template, variables) {
  return template.replace(/{{(\w+)}}/g, (_, key) => variables[key] || '');
}

// POST /api/notifications/dispatch — fan-out engine
// Body: { eventType, userId, variables: { orderId: '123', amount: '$99' } }
router.post('/dispatch', async (req, res) => {
  const { eventType, userId, variables = {} } = req.body;
  if (!eventType || !userId) {
    return res.status(400).json({ error: 'eventType and userId required' });
  }

  // Load template for this event
  const [template] = await db.select().from(notificationTemplates)
    .where(eq(notificationTemplates.eventType, eventType));
  if (!template) {
    return res.status(404).json({ error: `No template for event type: ${eventType}` });
  }

  const title = interpolate(template.titleTemplate, variables);
  const body = interpolate(template.bodyTemplate, variables);

  const createdNotifications = [];

  // Fan out to each channel this template supports
  for (const channel of template.channels) {
    // Check user preference for this channel + event type
    const [pref] = await db.select().from(notificationPreferences)
      .where(and(
        eq(notificationPreferences.userId, userId),
        eq(notificationPreferences.channel, channel),
        eq(notificationPreferences.eventType, eventType)
      ));

    // Default to enabled if no preference record exists
    if (pref && !pref.enabled) continue;

    const [notification] = await db.insert(notifications).values({
      userId,
      channel,
      title,
      body,
      type: variables.notificationType || 'info',
      referenceType: variables.referenceType || null,
      referenceId: variables.referenceId || null,
    }).returning();

    // Queue email and SMS for async delivery (in_app is real-time via SSE)
    if (channel !== 'in_app') {
      await db.insert(notificationQueue).values({
        notificationId: notification.id,
        channel,
        payload: { userId, title, body, ...variables },
      });
    }

    createdNotifications.push(notification);
  }

  res.status(201).json({ dispatched: createdNotifications.length, notifications: createdNotifications });
});

module.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.

```
const axios = require('axios');
const sgMail = require('@sendgrid/mail');
const twilio = require('twilio');
const { db } = require('../db');
const { notificationQueue, notifications } = require('../../shared/schema');
const { eq, sql } = require('drizzle-orm');

sgMail.setApiKey(process.env.SENDGRID_API_KEY);
const twilioClient = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN);

async function processQueue() {
  // Atomic claim: mark as 'processing' to prevent duplicates
  const claimed = await db.execute(sql`
    UPDATE notification_queue
    SET error_message = 'processing'
    WHERE id IN (
      SELECT id FROM notification_queue
      WHERE error_message IS DISTINCT FROM 'processing'
        AND attempts < max_attempts
        AND next_retry_at <= NOW()
      LIMIT 10
      FOR UPDATE SKIP LOCKED
    )
    RETURNING *
  `);

  for (const job of claimed.rows) {
    const { id, channel, payload, attempts } = job;
    let success = false;
    let errorMsg = null;

    try {
      if (channel === 'email' && payload.userEmail) {
        await sgMail.send({
          to: payload.userEmail,
          from: process.env.FROM_EMAIL || 'noreply@yourdomain.com',
          subject: payload.title,
          text: payload.body,
        });
        success = true;
      } else if (channel === 'sms' && payload.userPhone) {
        await twilioClient.messages.create({
          body: `${payload.title}: ${payload.body}`,
          from: process.env.TWILIO_PHONE_NUMBER,
          to: payload.userPhone,
        });
        success = true;
      }
    } catch (err) {
      errorMsg = err.message;
    }

    if (success) {
      // Mark delivered
      await db.execute(sql`DELETE FROM notification_queue WHERE id = ${id}`);
      await db.execute(sql`UPDATE notifications SET status = 'sent', sent_at = NOW() WHERE id = ${job.notification_id}`);
    } else {
      // Exponential backoff: 1min, 2min, 4min
      const nextRetrySeconds = Math.pow(2, attempts) * 60;
      await db.execute(sql`
        UPDATE notification_queue
        SET attempts = attempts + 1,
            next_retry_at = NOW() + INTERVAL '${nextRetrySeconds} seconds',
            error_message = ${errorMsg || 'Unknown error'}
        WHERE id = ${id}
      `);

      if (attempts + 1 >= job.max_attempts) {
        await db.execute(sql`UPDATE notifications SET status = 'failed' WHERE id = ${job.notification_id}`);
      }
    }
  }
}

// Run processor every 30 seconds on Reserved VM
setInterval(processQueue, 30000);
processQueue(); // Run once on startup

module.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.

```
// In-memory SSE subscriber map: userId -> res
const inAppSubscribers = new Map();

// GET /api/notifications/stream — SSE for in-app delivery
router.get('/stream', (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).end();

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no',
  });
  res.write(`data: {"type":"connected"}\n\n`);

  const pingInterval = setInterval(() => {
    try { res.write(': ping\n\n'); } catch (e) { clearInterval(pingInterval); }
  }, 30000);

  inAppSubscribers.set(userId, res);

  req.on('close', () => {
    clearInterval(pingInterval);
    inAppSubscribers.delete(userId);
  });
});

// Helper: push in-app notification to connected user
exports.pushInApp = (userId, notification) => {
  const res = inAppSubscribers.get(userId);
  if (res) {
    try {
      res.write(`data: ${JSON.stringify(notification)}\n\n`);
    } catch (e) {
      inAppSubscribers.delete(userId);
    }
  }
};

// Call pushInApp from the dispatch route after creating in_app notification:
// const { pushInApp } = require('./stream');
// 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.

```
// GET /api/notifications/unread-count
router.get('/unread-count', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const result = await db.execute(sql`
    SELECT COUNT(*) as count FROM notifications
    WHERE user_id = ${userId}
      AND channel = 'in_app'
      AND status != 'read'
  `);
  res.json({ count: parseInt(result.rows[0]?.count || 0) });
});

// PATCH /api/notifications/:id/read
router.patch('/:id/read', async (req, res) => {
  const userId = req.user?.id;
  await db.update(notifications)
    .set({ status: 'read', readAt: new Date() })
    .where(and(
      eq(notifications.id, parseInt(req.params.id)),
      eq(notifications.userId, userId)
    ));
  res.json({ ok: true });
});

// PATCH /api/notifications/read-all
router.patch('/read-all', async (req, res) => {
  const userId = req.user?.id;
  await db.execute(sql`
    UPDATE notifications
    SET status = 'read', read_at = NOW()
    WHERE user_id = ${userId} AND channel = 'in_app' AND status != 'read'
  `);
  res.json({ ok: true });
});

// server/index.js — require the queue processor to start on Reserved VM
const { processQueue } = require('./queue/processor');
// processQueue() is called inside processor.js via setInterval + immediate call
console.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 example

File: `server/queue/processor.js`

```javascript
const sgMail = require('@sendgrid/mail');
const twilio = require('twilio');
const { db } = require('../db');
const { sql } = require('drizzle-orm');

if (process.env.SENDGRID_API_KEY) sgMail.setApiKey(process.env.SENDGRID_API_KEY);
const twilioClient = process.env.TWILIO_ACCOUNT_SID
  ? twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN)
  : null;

async function processQueue() {
  const claimed = await db.execute(sql`
    UPDATE notification_queue
    SET error_message = 'processing'
    WHERE id IN (
      SELECT id FROM notification_queue
      WHERE (error_message IS NULL OR error_message != 'processing')
        AND attempts < max_attempts
        AND next_retry_at <= NOW()
      LIMIT 10
      FOR UPDATE SKIP LOCKED
    )
    RETURNING *
  `);

  for (const job of claimed.rows) {
    const { id, channel, payload, attempts, notification_id } = job;
    let success = false;
    let errorMsg = null;

    try {
      if (channel === 'email' && payload.userEmail && process.env.SENDGRID_API_KEY) {
        await sgMail.send({
          to: payload.userEmail,
          from: process.env.FROM_EMAIL,
          subject: payload.title,
          text: payload.body,
          html: `<p>${payload.body}</p>`,
        });
        success = true;
      } else if (channel === 'sms' && payload.userPhone && twilioClient) {
        await twilioClient.messages.create({
          body: `${payload.title}: ${payload.body}`,
          from: process.env.TWILIO_PHONE_NUMBER,
          to: payload.userPhone,
        });
        success = true;
      } else {
        // Missing config — mark as failed without retry
        errorMsg = `Missing config for channel: ${channel}`;
      }
    } catch (err) {
      errorMsg = err.message;
    }

    if (success) {
      await db.execute(sql`DELETE FROM notification_queue WHERE id = ${id}`);
      await db.execute(sql`UPDATE notifications SET status = 'sent', sent_at = NOW() WHERE id = ${notification_id}`);
    } else {
      const nextRetry = Math.pow(2, attempts) * 60;
```

## Common mistakes

- **Not using SKIP LOCKED in the queue processor** — Without SKIP LOCKED, if two queue processor runs overlap (which happens when setInterval fires before the previous run finishes), both claim the same jobs and the same email or SMS gets sent twice. Fix: 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.
- **Deploying on Autoscale instead of Reserved VM** — The queue processor uses setInterval which stops running when Autoscale puts the instance to sleep. Pending email and SMS notifications accumulate in the queue but are never processed. Fix: Deploy on Reserved VM. The process runs continuously, and setInterval fires every 30 seconds as expected.
- **Storing user email and phone number in the notification_queue payload** — If the user updates their email or phone number after a notification is queued but before it's delivered, the notification is sent to the old address. Fix: 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.
- **Not checking user preferences before creating notifications** — If you dispatch notifications without checking preferences, users who opted out of SMS alerts still get SMS notifications, leading to unsubscribes and spam reports. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/notification-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/notification-system
