How to build a notification service in Node.js
A notification service centralizes the logic for sending emails, push notifications, and in-app alerts, decoupling notification delivery from the business logic that triggers it. As the creator of CoreUI with 25 years of backend development experience, I’ve built notification systems for SaaS platforms where users receive transactional emails, browser push notifications, and real-time in-app alerts from a single, unified service. The architecture uses a BullMQ queue for reliability — notifications are enqueued by business logic and processed asynchronously by dedicated workers that retry on failure. This ensures notifications are delivered even if the email provider has a temporary outage.
Set up the notification queue and worker.
// src/notifications/notification.queue.js
import { Queue, Worker } from 'bullmq'
import nodemailer from 'nodemailer'
import webpush from 'web-push'
const connection = {
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT ?? 6379)
}
export const notificationQueue = new Queue('notifications', { connection })
// Email transporter
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
})
// Web push VAPID keys
webpush.setVapidDetails(
'mailto:[email protected]',
process.env.VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
)
const notificationWorker = new Worker(
'notifications',
async (job) => {
const { type, payload } = job.data
switch (type) {
case 'email':
return sendEmail(payload)
case 'push':
return sendPushNotification(payload)
case 'in-app':
return saveInAppNotification(payload)
default:
throw new Error(`Unknown notification type: ${type}`)
}
},
{ connection, concurrency: 10 }
)
notificationWorker.on('failed', (job, err) => {
console.error(`Notification ${job?.id} failed:`, err.message)
})
async function sendEmail({ to, subject, html }) {
await transporter.sendMail({
from: `"My App" <${process.env.SMTP_FROM}>`,
to,
subject,
html
})
}
async function sendPushNotification({ subscription, title, body, url }) {
const payload = JSON.stringify({ title, body, url })
await webpush.sendNotification(subscription, payload)
}
async function saveInAppNotification({ userId, message, type }) {
await prisma.notification.create({
data: { userId, message, type, read: false }
})
}
The worker dispatches to the appropriate handler based on type. Each handler fails independently — an email failure doesn’t affect push notification delivery. BullMQ retries failed jobs automatically.
Notification Service API
Enqueue notifications from business logic.
// src/notifications/notification.service.js
import { notificationQueue } from './notification.queue.js'
export const notify = {
email(to, subject, html) {
return notificationQueue.add('email', {
type: 'email',
payload: { to, subject, html }
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 }
})
},
push(subscription, title, body, url) {
return notificationQueue.add('push', {
type: 'push',
payload: { subscription, title, body, url }
}, { attempts: 2 })
},
inApp(userId, message, type = 'info') {
return notificationQueue.add('in-app', {
type: 'in-app',
payload: { userId, message, type }
})
}
}
// Usage in business logic
import { notify } from '../notifications/notification.service.js'
// After a user registers
await notify.email(user.email, 'Welcome!', '<h1>Welcome to our app!</h1>')
await notify.inApp(user.id, 'Your account has been created', 'success')
// After an order is placed
await notify.push(user.pushSubscription, 'Order placed', 'Your order #1234 is confirmed', '/orders/1234')
Best Practice Note
This is the same notification service pattern used in CoreUI SaaS backend templates. For in-app notifications, serve them to the frontend via WebSockets or SSE so they appear in real time without polling. For email templates, use a library like mjml or react-email to build responsive HTML emails. See how to queue background jobs in Node.js for the BullMQ foundation this service is built on.



