Skip to content

Building a Notification System

Aman Kumar Singh9 min read
Part 19 of 40From the Full Stack SaaS Masterclass series
Building a Notification System — article by Aman Kumar Singh

In Securing Your SaaS API we locked down who can call your endpoints and what they can touch once they're in. That closes out Module 2. Module 3 is where the product actually starts feeling like a product, and the first feature most SaaS apps need is a way to tell users something happened without them refreshing the page and hoping.

This is part of the Full Stack SaaS Masterclass, a build-it-for-real series taking a multi-tenant SaaS from an empty folder to production. Notifications sound like a small feature until you've built one. They touch your data model, your queue, your real-time layer, and eventually your email and push providers, so getting the foundation right here saves you from a rewrite three features later.

I'll scope this deliberately. We're building the core notification system: one table as the source of truth, a queue-backed fan-out to different channels, and real-time in-app delivery. The next article in the series covers transactional email in depth, so I'll only touch email here as one channel among several.

Why a naive approach falls apart fast

The first version of a notification feature is almost always the same: a controller creates a row when something happens, an endpoint lists rows for the current user, and a badge shows the unread count. That's genuinely fine for a while, and I'd tell you to start there.

It stops being fine the moment a second requirement shows up, and it always does. Someone asks for email notifications alongside the in-app ones. Someone else wants a digest instead of an email per event, because they got fifty emails in one afternoon and muted your domain. Then per-user preferences show up: this person wants Slack notifications for mentions but nothing for comments. Then a single event needs to fan out to multiple recipients. A comment on a shared document might need to notify five people, each with their own channel preferences and their own read state.

If your notification logic lives inline in whatever service triggered the event, every one of those requirements means touching business logic that has nothing to do with notifications. The fix is to treat "something happened" and "how the user finds out about it" as two separate concerns from day one, even if you only build one channel at first. That separation is cheap now. It gets expensive to retrofit later.

One system of record, many channels

The core model is a notifications table that represents what happened and who it's for, decoupled from how it gets delivered. Delivery is a separate concept: a channel attempt, tracked per notification per channel, so you can tell whether the email actually sent without polluting the notification record itself.

create table notifications (
  id uuid primary key default gen_random_uuid(),
  organization_id uuid not null references organizations(id),
  recipient_id uuid not null references users(id),
  type text not null, -- e.g. 'comment.mentioned', 'invoice.paid'
  payload jsonb not null default '{}',
  read_at timestamptz,
  created_at timestamptz not null default now()
);

create index idx_notifications_recipient
  on notifications (recipient_id, created_at desc)
  where read_at is null;

create table notification_deliveries (
  id uuid primary key default gen_random_uuid(),
  notification_id uuid not null references notifications(id) on delete cascade,
  channel text not null, -- 'in_app', 'email', 'push'
  status text not null default 'pending', -- 'pending', 'sent', 'failed'
  attempted_at timestamptz,
  error text,
  unique (notification_id, channel)
);

The partial index on unread notifications matters more than it looks. Most queries against this table are "give me this user's unread notifications," and indexing only the unread rows keeps that index small and fast even after a user has accumulated years of read history. The unique (notification_id, channel) constraint on deliveries buys you idempotency almost for free: if a queue job retries, an upsert on that constraint means you never send the same email twice.

Creating a notification is a service call, not something scattered through every feature that might trigger one:

// notifications.service.ts
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { PrismaService } from '../prisma/prisma.service';

interface CreateNotificationInput {
  organizationId: string;
  recipientId: string;
  type: string;
  payload: Record<string, unknown>;
}

@Injectable()
export class NotificationsService {
  constructor(
    private prisma: PrismaService,
    @InjectQueue('notification-delivery') private deliveryQueue: Queue,
  ) {}

  async create(input: CreateNotificationInput) {
    const notification = await this.prisma.notification.create({ data: input });

    await this.deliveryQueue.add(
      'deliver',
      { notificationId: notification.id },
      { attempts: 3, backoff: { type: 'exponential', delay: 2000 } },
    );

    return notification;
  }
}

The feature that triggers a notification (a comment service, a billing service, whatever) calls create() and moves on. It doesn't know or care whether email is enabled, whether the user muted this notification type, or whether push delivery is even configured. That routing decision lives entirely inside the delivery worker.

Fan-out with a queue, not a request-cycle side effect

Sending the notification synchronously inside the request that triggered it is tempting, and it's a mistake. If a comment triggers a notification and the notification write blocks on an email provider call, a slow or down email API now makes commenting slow or broken. Decoupling with a queue means the triggering request only has to write one row and enqueue one job. Both are fast, local operations.

BullMQ on Redis is a good fit here since we're already running Redis for caching elsewhere in the stack. The worker reads the notification, checks the recipient's preferences, and fans out to whichever channels apply:

// notification-delivery.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { PrismaService } from '../prisma/prisma.service';
import { PreferencesService } from './preferences.service';
import { EmailChannel } from './channels/email.channel';
import { InAppChannel } from './channels/in-app.channel';

@Processor('notification-delivery')
export class NotificationDeliveryProcessor extends WorkerHost {
  constructor(
    private prisma: PrismaService,
    private preferences: PreferencesService,
    private emailChannel: EmailChannel,
    private inAppChannel: InAppChannel,
  ) {
    super();
  }

  async process(job: Job<{ notificationId: string }>) {
    const notification = await this.prisma.notification.findUniqueOrThrow({
      where: { id: job.data.notificationId },
    });

    const enabledChannels = await this.preferences.getEnabledChannels(
      notification.recipientId,
      notification.type,
    );

    if (enabledChannels.has('in_app')) {
      await this.inAppChannel.deliver(notification);
    }
    if (enabledChannels.has('email')) {
      await this.emailChannel.deliver(notification);
    }
  }
}

Each channel is its own class implementing a small deliver(notification) interface, so adding push or Slack later means writing a new channel, not touching the processor. Each channel handles its own notification_deliveries upsert too, so a failure in the email channel doesn't roll back the in-app delivery that already succeeded.

Real-time delivery for in-app notifications

A polling endpoint that the client hits every few seconds works, and it's a reasonable starting point if you're validating the feature. It doesn't scale well once you have enough concurrent users to turn "poll every ten seconds" into a meaningful chunk of your request volume, and it means users see new notifications with a delay instead of instantly.

WebSockets are the natural next step, and NestJS's WebSocket gateway makes this straightforward to bolt onto the existing delivery pipeline. The in-app channel, instead of just writing to Postgres, also pushes to any connected client for that user:

// in-app.channel.ts
import { Injectable } from '@nestjs/common';
import { NotificationsGateway } from './notifications.gateway';
import { PrismaService } from '../prisma/prisma.service';
import { Notification } from '@prisma/client';

@Injectable()
export class InAppChannel {
  constructor(
    private prisma: PrismaService,
    private gateway: NotificationsGateway,
  ) {}

  async deliver(notification: Notification) {
    await this.prisma.notificationDelivery.upsert({
      where: { notificationId_channel: { notificationId: notification.id, channel: 'in_app' } },
      create: { notificationId: notification.id, channel: 'in_app', status: 'sent', attemptedAt: new Date() },
      update: { status: 'sent', attemptedAt: new Date() },
    });

    this.gateway.pushToUser(notification.recipientId, notification);
  }
}

If your app runs multiple Node instances, a client connected to instance A won't receive a push emitted from instance B unless you fan the event across instances too. Redis pub/sub, or the @nestjs/platform-socket.io Redis adapter, handles this: the gateway publishes to a Redis channel, and every instance subscribed to that channel forwards the message to any locally connected sockets. This is the point where Redis earns its keep for a second reason beyond caching and queues. Set it up before you scale horizontally. Discovering the gap after a user reports missed notifications in production is a bad way to learn it.

Preferences and the tradeoff of building them too early

Per-user, per-type, per-channel preferences are the feature everyone asks for and almost nobody needs on day one. A notification_preferences table keyed on (user_id, notification_type, channel) with a boolean is enough to start: default everything to on, let users opt out of specific types or channels, and check that table in the delivery worker before sending.

Here's the tradeoff worth naming honestly. Building a fully granular preference system (frequency, quiet hours, digest batching, channel-specific formatting) before you have users asking for it is speculative complexity. It's tempting because notification preferences feel like an obviously good feature, but each additional dimension is a new column, a new UI control, and a new thing the delivery worker has to check. Start with a coarse on/off per type and per channel. Add batching or digests only when users are actually complaining about volume, since that requirement changes the delivery model meaningfully: you're now aggregating notifications over a time window rather than delivering each one immediately.

Production pitfalls

Duplicate notifications are the most common bug, usually from a queue job retrying after a transient failure. The unique (notification_id, channel) constraint plus an upsert in every channel handler is the fix. Without it, a retried job re-sends the same email.

Notification volume from a single event is the second one. A comment on a shared document might need to notify every collaborator, and if your trigger code loops over recipients and calls create() synchronously inside a request handler, a document with a hundred watchers turns one comment into a hundred synchronous writes and a hundred queue jobs enqueued in the request cycle. Batch the creates in a single transaction and enqueue a single fan-out job that reads all recipients, rather than looping in the request path.

Silent delivery failures are easy to miss because nothing in the UI tells you email delivery is broken. Track notification_deliveries.status and error, and alert when the failure rate for a channel crosses a threshold over a rolling window. Alerting per message would be too noisy to act on.

Finally, don't let the notifications table become an unbounded log. Every event that could conceivably notify someone eventually does, and this table grows fast. Decide early whether old, read notifications get archived or deleted after a retention period, and build that into the design rather than discovering the table is your largest one during a routine maintenance window.

Key takeaways

  • Separate "something happened" from "how the user finds out." A `create()` call in the triggering service should not know about channels, preferences, or delivery mechanics.
  • Track delivery per channel in its own table with a uniqueness constraint, so retries are idempotent and you can see exactly which channel failed for a given notification.
  • Deliver through a queue, not inline in the request cycle, so a slow email provider never makes an unrelated feature slow.
  • Use WebSockets for real-time in-app delivery once polling stops being good enough, and route pushes through Redis pub/sub if you run more than one server instance.
  • Start with coarse on/off preferences per type and channel; add digesting, batching, or quiet hours only once real usage demands it.
  • Plan for retention and failure visibility from the start. An unbounded notifications table and silent delivery failures are both easy to avoid early and painful to fix later.

Frequently asked questions

Should notifications be stored in one table or split by type?

One table with a `type` and a `payload` JSONB column scales better than a table per notification type. You get a single place to query unread counts and history, and adding a new notification type is a code change, not a migration.

How do I avoid sending duplicate notifications when a queue job retries?

Give delivery attempts a uniqueness constraint on `(notification_id, channel)` and upsert into it from the channel handler. A retried job then updates the same row instead of creating a second delivery.

Do I need WebSockets for notifications, or is polling good enough?

Polling is a reasonable starting point and easier to build. Move to WebSockets once you have enough concurrent users that polling adds meaningful request volume, or once users need to see notifications appear instantly rather than within a polling interval.

How should notification preferences be modeled?

Start with a table keyed on user, notification type, and channel, with a simple boolean. Resist adding digest batching, frequency controls, or quiet hours until users are actually asking for them, since each one changes how delivery timing works, well beyond what gets stored.

What's the biggest mistake teams make building notifications?

Putting delivery logic inline in the feature that triggers the notification. It works at first, then every new channel or preference requirement means touching unrelated business logic, instead of a self-contained notification service and queue.

Should notification data ever be deleted?

Yes, decide on a retention policy early, for example archiving or deleting read notifications after a fixed period. The table grows continuously since almost every user action can trigger one, and it's much easier to build retention in from the start than to add it after the table becomes a performance problem.

Related articles

Scaling a SaaS Beyond One Server — Aman Kumar Singh
Product Analytics and Event Tracking — Aman Kumar Singh
Sending Emails from NestJS — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.