Back to Blog

How to Build a Notification System That Scales

How to Build a Notification System That Scales

Notifications are one of those features that seem simple until you actually build them. Send an email when something happens — how hard can it be? Then you add in-app notifications, push notifications, SMS, user preferences, batching, digests, templates, and suddenly you have an entire subsystem that touches every part of your application.

We built the notification system for MindHyv from scratch — an all-in-one business platform where entrepreneurs manage social media, bookings, invoicing, and selling. Notifications are critical: a missed booking reminder or a delayed payment alert is a real business problem for our users. Here is the architecture we landed on after iterating through several approaches.

Start with Events, Not Channels

The most common mistake is building notifications channel-first. You write a sendEmail() call in your booking handler, a sendPush() in your payment handler, and before long, notification logic is scattered across your entire codebase.

Instead, start with events. Every notification begins as a domain event:

type NotificationEvent = {
  type: string;
  userId: string;
  data: Record<string, unknown>;
  timestamp: Date;
};

// Examples
const events: NotificationEvent[] = [
  {
    type: "booking.confirmed",
    userId: "user_123",
    data: { bookingId: "bk_456", clientName: "Jane", date: "2026-03-15" },
    timestamp: new Date(),
  },
  {
    type: "invoice.paid",
    userId: "user_123",
    data: { invoiceId: "inv_789", amount: 150.0, currency: "USD" },
    timestamp: new Date(),
  },
  {
    type: "review.received",
    userId: "user_123",
    data: { reviewId: "rev_012", rating: 5, reviewerName: "Alex" },
    timestamp: new Date(),
  },
];

Your business logic emits events. A separate notification service consumes those events and decides what to send, where to send it, and when. This separation is critical for maintainability. When you add a new channel (say, Slack notifications), you change the notification service — not every feature in your app.

Push notification appearing on a mobile device screen

Database Schema

The notification system needs its own tables. Here is the schema we use in Supabase (PostgreSQL):

-- Notification types and their default channel configuration
CREATE TABLE notification_types (
  id TEXT PRIMARY KEY,                    -- e.g., 'booking.confirmed'
  title TEXT NOT NULL,
  description TEXT,
  category TEXT NOT NULL,                 -- 'bookings', 'payments', 'reviews'
  default_channels TEXT[] NOT NULL,       -- {'in_app', 'email'}
  can_disable BOOLEAN DEFAULT true,
  template_id TEXT
);

-- User preferences per notification type
CREATE TABLE notification_preferences (
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  notification_type_id TEXT REFERENCES notification_types(id),
  channels TEXT[] NOT NULL,               -- User's chosen channels
  enabled BOOLEAN DEFAULT true,
  PRIMARY KEY (user_id, notification_type_id)
);

-- The actual notifications
CREATE TABLE notifications (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  type TEXT NOT NULL REFERENCES notification_types(id),
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  data JSONB DEFAULT '{}',
  read_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Delivery tracking per channel
CREATE TABLE notification_deliveries (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  notification_id UUID REFERENCES notifications(id) ON DELETE CASCADE,
  channel TEXT NOT NULL,                  -- 'email', 'push', 'sms'
  status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'sent', 'failed', 'bounced'
  sent_at TIMESTAMPTZ,
  error TEXT,
  metadata JSONB DEFAULT '{}'             -- Provider-specific data (message IDs, etc.)
);

-- Indexes for common queries
CREATE INDEX idx_notifications_user_unread
  ON notifications (user_id, created_at DESC)
  WHERE read_at IS NULL;

CREATE INDEX idx_deliveries_pending
  ON notification_deliveries (status, channel)
  WHERE status = 'pending';

The key design decisions here: notifications and deliveries are separate tables. A single notification (the in-app record) can have multiple deliveries (email sent, push sent). This lets you track delivery status per channel independently and retry failed deliveries without duplicating the notification itself.

We covered more on RLS patterns for this kind of multi-user data in our post on Row-Level Security in Supabase.

The Routing Layer

The routing layer sits between events and channels. It resolves user preferences and decides which channels to use:

type Channel = "in_app" | "email" | "push" | "sms";

type ResolvedNotification = {
  userId: string;
  type: string;
  title: string;
  body: string;
  data: Record<string, unknown>;
  channels: Channel[];
};

async function routeNotification(
  event: NotificationEvent
): Promise<ResolvedNotification | null> {
  // Get notification type config
  const notificationType = await db
    .selectFrom("notification_types")
    .where("id", "=", event.type)
    .selectAll()
    .executeTakeFirst();

  if (!notificationType) return null;

  // Get user preferences (fall back to defaults)
  const preferences = await db
    .selectFrom("notification_preferences")
    .where("user_id", "=", event.userId)
    .where("notification_type_id", "=", event.type)
    .selectAll()
    .executeTakeFirst();

  if (preferences && !preferences.enabled) return null;

  const channels = preferences?.channels ?? notificationType.default_channels;

  // Render the template
  const { title, body } = renderTemplate(notificationType.template_id, event.data);

  return {
    userId: event.userId,
    type: event.type,
    title,
    body,
    data: event.data,
    channels: channels as Channel[],
  };
}

Channel Dispatchers

Each channel gets its own dispatcher. This keeps channel-specific logic (API calls, formatting, rate limits) isolated:

interface ChannelDispatcher {
  send(notification: ResolvedNotification): Promise<DeliveryResult>;
}

const dispatchers: Record<Channel, ChannelDispatcher> = {
  in_app: {
    async send(notification) {
      const record = await db
        .insertInto("notifications")
        .values({
          user_id: notification.userId,
          type: notification.type,
          title: notification.title,
          body: notification.body,
          data: JSON.stringify(notification.data),
        })
        .returning("id")
        .executeTakeFirstOrThrow();

      // Push to real-time subscribers
      await supabase.channel(`notifications:${notification.userId}`).send({
        type: "broadcast",
        event: "new-notification",
        payload: { id: record.id, title: notification.title },
      });

      return { success: true, messageId: record.id };
    },
  },

  email: {
    async send(notification) {
      const user = await getUser(notification.userId);
      const html = renderEmailTemplate(notification.type, notification.data);

      const result = await resend.emails.send({
        from: "MindHyv <[email protected]>",
        to: user.email,
        subject: notification.title,
        html,
      });

      return { success: true, messageId: result.id };
    },
  },

  push: {
    async send(notification) {
      const tokens = await getUserPushTokens(notification.userId);
      if (tokens.length === 0) return { success: true, messageId: "no-tokens" };

      const results = await Promise.allSettled(
        tokens.map((token) =>
          sendPushNotification({
            token,
            title: notification.title,
            body: notification.body,
            data: notification.data,
          })
        )
      );

      // Clean up expired tokens
      const expired = results
        .filter((r) => r.status === "rejected")
        .map((_, i) => tokens[i]);
      if (expired.length > 0) {
        await removeExpiredTokens(notification.userId, expired);
      }

      return { success: true, messageId: "batch" };
    },
  },

  sms: {
    async send(notification) {
      const user = await getUser(notification.userId);
      if (!user.phone) return { success: false, error: "No phone number" };

      const result = await twilio.messages.create({
        to: user.phone,
        from: process.env.TWILIO_NUMBER,
        body: `${notification.title}: ${notification.body}`,
      });

      return { success: true, messageId: result.sid };
    },
  },
};

Batching and Digests

Nobody wants 47 individual emails about 47 new reviews. Batching groups related notifications into a single delivery. Digests aggregate all notifications over a time window into one summary.

The approach: instead of delivering immediately, buffer notifications in a staging table and flush them on a schedule.

// Instead of sending immediately, stage for batching
async function stageForBatch(notification: ResolvedNotification) {
  await db.insertInto("notification_batch_queue").values({
    user_id: notification.userId,
    type: notification.type,
    title: notification.title,
    body: notification.body,
    data: JSON.stringify(notification.data),
    batch_key: `${notification.userId}:${notification.type}`,
    created_at: new Date(),
  });
}

// Run every 5 minutes (or whatever interval makes sense)
async function flushBatches() {
  const batches = await db
    .selectFrom("notification_batch_queue")
    .where("created_at", "<", new Date(Date.now() - 5 * 60 * 1000))
    .groupBy("batch_key")
    .select([
      "batch_key",
      sql`array_agg(id)`.as("ids"),
      sql`count(*)`.as("count"),
      sql`json_agg(json_build_object('title', title, 'body', body))`.as("items"),
    ])
    .execute();

  for (const batch of batches) {
    if (batch.count === 1) {
      // Single notification — send normally
      await sendSingle(batch);
    } else {
      // Multiple — send as batch
      await sendBatched(batch);
    }

    // Clean up queue
    await db
      .deleteFrom("notification_batch_queue")
      .where("id", "in", batch.ids)
      .execute();
  }
}

For daily digests, we use a scheduled background job (see our comparison of background job tools) that runs at the user’s preferred time, collects everything from the last 24 hours, and sends a single summary email.

Dashboard displaying alert system notifications and status indicators

Real-Time In-App Notifications

For MindHyv, in-app notifications need to appear instantly. We use Supabase Realtime for this. The client subscribes to a channel scoped to their user ID:

// Client-side (Svelte)
import { onMount, onDestroy } from "svelte";
import { supabase } from "$lib/supabase";

let notifications = $state<Notification[]>([]);
let unreadCount = $state(0);

onMount(() => {
  // Load existing notifications
  loadNotifications();

  // Subscribe to new ones
  const channel = supabase
    .channel(`notifications:${userId}`)
    .on("broadcast", { event: "new-notification" }, (payload) => {
      notifications = [payload.payload, ...notifications];
      unreadCount++;
    })
    .subscribe();

  return () => {
    supabase.removeChannel(channel);
  };
});

async function markAsRead(notificationId: string) {
  await supabase
    .from("notifications")
    .update({ read_at: new Date().toISOString() })
    .eq("id", notificationId);

  unreadCount = Math.max(0, unreadCount - 1);
}

We covered real-time patterns in more depth in our post on building real-time features with Supabase.

Templates and Rendering

Do not hardcode notification copy in your dispatchers. Use a template system that separates content from delivery:

const templates: Record<string, TemplateConfig> = {
  "booking.confirmed": {
    title: "Booking confirmed",
    body: "{{clientName}} booked {{serviceName}} for {{date}}",
    email: {
      subject: "New booking: {{clientName}} on {{date}}",
      templateId: "booking-confirmed", // Resend/SendGrid template
    },
  },
  "invoice.paid": {
    title: "Payment received",
    body: "{{clientName}} paid {{amount}} for invoice #{{invoiceNumber}}",
    email: {
      subject: "Payment received: {{amount}} from {{clientName}}",
      templateId: "invoice-paid",
    },
  },
};

function renderTemplate(
  templateId: string,
  data: Record<string, unknown>
): { title: string; body: string } {
  const template = templates[templateId];
  if (!template) throw new Error(`Unknown template: ${templateId}`);

  return {
    title: interpolate(template.title, data),
    body: interpolate(template.body, data),
  };
}

function interpolate(text: string, data: Record<string, unknown>): string {
  return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(data[key] ?? ""));
}

This approach lets you update copy without deploying code, localize notifications for different languages, and A/B test notification wording independently.

Message inbox showing organized notification entries

Common Mistakes

Forgetting to handle unsubscribes. If a user disables email notifications for a category, your system must respect that. Build the preferences check into the routing layer, not as an afterthought in each dispatcher.

Not tracking deliveries. Without a delivery log, debugging “I never got that email” becomes guesswork. Track every delivery attempt with status, timestamp, and provider response.

Sending too many notifications. Just because you can notify on every event does not mean you should. Start conservative. It is easier to add notification types than to rebuild trust after spamming users. In MindHyv, we default new notification types to in-app only, with email as opt-in.

Over-engineering on day one. You probably do not need SMS and push on launch day. Start with in-app and email. The event-driven architecture makes adding channels later straightforward — you add a new dispatcher and a new column in preferences.

The Architecture in Summary

The flow is: Business Event -> Notification Router -> Preference Check -> Template Rendering -> Channel Dispatchers -> Delivery Tracking.

Each layer has a single responsibility. The business logic does not know about channels. The dispatchers do not know about preferences. The templates do not know about delivery. This separation has let us evolve the MindHyv notification system from simple email alerts to a full multi-channel system without rewriting the core.

The schema and patterns here are not theoretical. They are running in production, handling thousands of notifications daily for real businesses on MindHyv. The foundation scales well because the hard part is not sending messages — it is deciding what to send, to whom, and when.

If you are building something similar, reach out at [email protected].