Notifications in TypeScript: best practices for implementing a notification system

Hugo Bessa
August 14, 2025

A well-designed notification system is crucial for any modern application, ensuring users stay informed about important updates, actions, or reminders. Whether you're building a SaaS platform, an e-commerce app, or an internal tool, implementing notifications effectively in TypeScript can significantly enhance user engagement and system reliability.

In this post, we'll explore the best practices for designing a robust notification system that is auditable, easy to manage (including canceling and retrying notifications), and supports scheduled notifications. We'll also discuss why using VintaSend can save you time and effort.

Table of Contents

Best practices for a notification system in TypeScript

1. Define notification types and channels

Different notifications serve different purposes. Some require immediate attention (e.g., security alerts), while others can be passive (e.g., weekly summaries). Categorize them and define the delivery channels:

  • Email - Ideal for transactional and long-form content.
  • SMS - Best for urgent, short messages.
  • Push notifications - Great for mobile and desktop alerts.
  • Web notifications - Useful for real-time updates in web apps.
  • In-app messages - Non-intrusive updates within the application.

2. Use a centralized and auditable notification service

Instead of scattering notification logic across your codebase, create a dedicated service or module that handles all notifications. This improves maintainability, scalability, and auditability.

Store notification records in a database to track delivery status, retries, and cancellations. Log all sent notifications, including metadata (timestamp, status, user preferences, etc.).

Example TypeScript service:

class NotificationService {
  private notificationsDB: Map<string, any> = new Map();

  sendNotification(id: string, type: string, recipient: string, content: string) {
    this.notificationsDB.set(id, { type, recipient, content, status: 'pending' });
    // Implement sending logic based on type
  }

  getNotificationStatus(id: string) {
    return this.notificationsDB.get(id);
  }
}

3. Ensure asynchronous processing with retry and cancel support

Notifications should not slow down your application. Use background jobs and queues to handle processing asynchronously, while also implementing retry logic and cancellation support.

Example using BullMQ with Redis:

import { Queue, Job } from 'bullmq';
const notificationQueue = new Queue('notifications');

async function sendNotification(job: Job) {
  try {
    // Your sending logic here
    console.log(`Sending notification to ${job.data.recipient}`);
  } catch (error) {
    throw new Error('Notification failed');
  }
}

notificationQueue.process(async (job) => sendNotification(job));

For cancellation:

async function cancelNotification(jobId: string) {
  await notificationQueue.remove(jobId);
}

4. Implement a subscription and preference system

Users should have control over which notifications they receive. Implement settings that allow them to manage their preferences effectively.

type NotificationPreferences = {
  email: boolean;
  sms: boolean;
  push: boolean;
};

5. Support scheduled notifications

Some notifications need to be sent at a future time (e.g., reminders, promotions). Use job schedulers to handle this efficiently.

Example using BullMQ delayed jobs:

notificationQueue.add(
  'sendEmail',
  { to: 'user@example.com', subject: 'Upcoming Event', body: 'Reminder: Your event is tomorrow.' },
  { delay: 24 * 60 * 60 * 1000 } // Schedule for 24 hours later
);

Why use VintaSend

Instead of reinventing the wheel, you can use VintaSend, an open-source TypeScript library that simplifies the implementation of notification systems, focusing on flexibility and scalability. Here's why it's a great choice:

Storing notifications in a database

This package relies on a data store to record all the notifications that will be sent. It also keeps its state column up to date.

Scheduling notifications

Storing notifications to be sent in the future. The notification's context for rendering the template is only evaluated at the moment the notification is sent due to the library's context generation registry.

On scheduled notifications, the package only gets the notification context (information to render the templates) at the send time, so we always get the most up-to-date information.

Flexible storing strategy

Your project's database is getting slow after you created the first million notifications? You can migrate to a faster NoSQL database in the blink of an eye without affecting how you send the notifications.

Flexible send adapters

Your project probably will need to change how it sends notifications over time. This package allows you to change the adapter without having to change how notification templates are rendered or how the notifications themselves are stored.

Flexible template renderers

Want to start managing your templates with a third-party tool (so non-technical people can help maintain them)? Or even choose a more powerful rendering engine? You can do it independently of how you send the notifications or store them in the database.

Sending notifications in background jobs

This package supports using job queues to send notifications from separate processes. This may be helpful to free up the HTTP server from processing heavy notifications during the request time.

Typed and extensible

Since it's built in TypeScript, it provides full type safety, ensuring fewer runtime errors and easier code maintenance.

Customizable, community-driven, and open source

VintaSend has multiple officially-supported implementations for storage backends, sending adapters, template rendering, etc.

How does VintaSend help us send, schedule, manage, and audit notifications?

The VintaSend package provides a service class that allows the user to store and send notifications, scheduled or not. It relies on dependency injection to define how to store/retrieve, render the notification templates, and send notifications. This architecture allows us to swap each part without changing the code we actually use to send the notifications.

Scheduled notifications

VintaSend schedules notifications by creating them in the database for sending when the sendAfter value has passed. The sending isn't done automatically, but we have a service method called sendPendingNotifications to send all pending notifications found in the database.

You need to call the sendPendingNotifications service method in a cron job or a tool for running periodic jobs.

Keeping the content up-to-date in scheduled notifications

The NotificationService stores every notification in a database. This helps us audit and manage our notifications. At the same time, notifications usually have a context that's used to hydrate its template with data. If we stored the context directly in the notification records, we'd have to update it anytime the context changes.

Instead of storing the context itself, we store a reference to a Context Generator class and the parameters it requires (like IDs, flags, types, etc.) so we generate the context only when the notification is sent. This ensures we're always getting the most up-to-date context when sending notifications. We also store the generated context after we send the notification for auditing purposes.

Getting started with VintaSend

To start using VintaSend, you just need to initialize the notification service and start using it to manage your notifications.

import { VintaSendFactory } from 'vintasend';
import type { ContextGenerator } from 'vintasend';

// context generator for Welcome notification
class WelcomeContextGenerator extends ContextGenerator {
  async generate ({ userId }: { userId: number }): { firstName: string } {
    const user = await getUserById(userId);  // example
    return {
      firstName: user.firstName,
    };
  }
  import { VintaSendFactory } from 'vintasend';
import type { ContextGenerator } from 'vintasend';

// context generator for Welcome notification
class WelcomeContextGenerator extends ContextGenerator {
  async generate ({ userId }: { userId: number }): { firstName: string } {
    const user = await getUserById(userId);  // example
    return {
      firstName: user.firstName,
    };
  }
}

// context map for generating the context of each notification
export const contextGeneratorsMap = {
  welcome: new WelcomeContextGenerator(),
} as const;

// type config definition, so all modules use the same types
export type NotificationTypeConfig = {
  ContextMap: typeof contextGeneratorsMap;
  NotificationIdType: number;
  UserIdType: number;
};

export function getNotificationService() {
  /* 
   Function to instantiate the notificationService 
   The Backend, Template Renderer, Logger, and Adapter used here are not included
     here and should be installed and imported separately or manually defined if 
     the existing implementations don't support the specific use case.
  */
  const backend = new MyNotificationBackendFactory<NotificationTypeConfig>().create();
  const templateRenderer = new MyNotificationAdapterFactory<NotificationTypeConfig>().create();
  const adapter = new MyNotificationAdapterFactory<NotificationTypeConfig>().create(
    templateRenderer, true
  );
  return new VintaSendFactory<NotificationTypeConfig>().create(
    [adapter],
    backend,
    new MyLogger(loggerOptions),
    contextGeneratorsMap,
  );
}

export function sendWelcomeEmail(userId: number) {
  /* sends the Welcome email to a user */
  const vintasend = getNotificationService();
  const now = new Date();

  vintasend.createNotification({
    userId: user.id,
    notificationType: 'EMAIL',
    title: 'Welcome Email',
    contextName: 'welcome',
    contextParameters: { userId },
    sendAfter: now,
    bodyTemplate: './src/email-templates/auth/welcome/welcome-body.html.template',
    subjectTemplate: './src/email-templates/auth/welcome/welcome-subject.txt.template',
    extraParams: {},
  });
} 

Notification systems look easy to implement, but you should make them future-proof

Building a reliable notification system in TypeScript requires thoughtful design and best practices like centralization, asynchronous processing, auditing, and preference management. Instead of building everything from scratch, VintaSend provides a robust and flexible solution that streamlines notification handling.

Want to make your notification system more auditable and efficient?
Explore our GitHub repository at try it out!‍