code examples

Sent logo
Sent TeamMay 3, 2025 / code examples / Article

Build a Production-Ready SMS Scheduler with Node.js, Fastify, and Sinch

A step-by-step guide to creating an SMS scheduling application using Node.js, Fastify, Sinch SMS API, PostgreSQL, and Prisma, covering setup, database, API, scheduling, and error handling.

This guide provides a step-by-step walkthrough for building a robust SMS scheduling and reminder application using Node.js, the Fastify web framework, the Sinch SMS API, and PostgreSQL with Prisma. You'll learn how to create an API endpoint to schedule reminders, store them in a database, and use a background job to send SMS messages via Sinch at the designated time.

We aim to create a reliable system capable of handling scheduled messages, including proper error handling, logging, and configuration management, suitable for production environments.

Project Overview and Goals

What We'll Build:

  • A Node.js application using the Fastify framework.
  • An API endpoint (POST /reminders) to accept SMS reminder requests (recipient number, message, scheduled time).
  • Integration with PostgreSQL using the Prisma ORM to store reminder details.
  • A background scheduler (node-cron) to periodically check for due reminders.
  • Integration with the Sinch SMS API via the @sinch/sdk-core to send the SMS messages.
  • Robust logging, error handling, and configuration management.

Problem Solved:

This application addresses the need to reliably send SMS messages at a future specified time, a common requirement for appointment reminders, notifications, marketing campaigns, and other time-sensitive communications.

Technologies Used:

  • Node.js (v20+): A JavaScript runtime built on Chrome's V8 engine. Chosen for its asynchronous nature and large ecosystem, suitable for I/O-bound tasks like API handling and external service integration. Fastify v5 requires Node.js v20 or higher.
  • Fastify (v5+): A high-performance, low-overhead web framework for Node.js. Chosen for its speed, developer experience, and robust plugin architecture.
  • Sinch SMS API (@sinch/sdk-core): Provides the functionality to send SMS messages globally. Chosen for its direct integration capabilities via the Node.js SDK.
  • PostgreSQL: A powerful, open-source object-relational database system. Chosen for its reliability, feature set, and strong community support.
  • Prisma: A next-generation Node.js and TypeScript ORM. Chosen for simplifying database access, migrations, and type safety.
  • node-cron: A simple cron-like job scheduler for Node.js. Chosen for its ease of use in running periodic tasks.
  • dotenv: A zero-dependency module that loads environment variables from a .env file. Essential for managing configuration and secrets securely.
  • pino-pretty: A development dependency to make Pino logs human-readable.

System Architecture:

mermaid
graph LR
    A[Client/User] -- HTTP POST /reminders --> B(Fastify API);
    B -- Writes Reminder --> C(PostgreSQL Database);
    D(node-cron Scheduler) -- Runs Periodically --> E{Check DB for Due Reminders};
    E -- Finds Due Reminder --> F(Sinch Service Module);
    F -- Sends SMS Request --> G(Sinch SMS API);
    G -- Delivers SMS --> H(Recipient Phone);
    E -- Updates Status --> C;
    B -- Logs --> I(Console/Log File);
    F -- Logs --> I;
    D -- Logs --> I;

    style B fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#ccf,stroke:#333,stroke-width:2px
    style D fill:#ff9,stroke:#333,stroke-width:2px
    style F fill:#9cf,stroke:#333,stroke-width:2px
    style G fill:#f66,stroke:#333,stroke-width:2px

Prerequisites:

  • Node.js v20.0.0 or later installed.
  • npm or yarn package manager.
  • Access to a PostgreSQL database instance (local or cloud).
  • A Sinch account with API credentials (Project ID, Key ID, Key Secret) and a provisioned Sinch phone number or Service Plan ID capable of sending SMS.
  • Basic familiarity with Node.js, APIs, and databases.
  • curl or a similar tool (like Postman) for testing the API.

Final Outcome:

By the end of this guide, you will have a functional Node.js application that can:

  1. Accept SMS reminder requests via an API endpoint.
  2. Store these requests securely in a PostgreSQL database.
  3. Automatically send the SMS messages at their scheduled times using Sinch.
  4. Log activities and errors for monitoring and troubleshooting.

1. Setting up the Project

Let's initialize the Node.js project, install dependencies, and configure the basic structure.

1. Create Project Directory:

Open your terminal and create a new directory for the project.

bash
mkdir sinch-fastify-scheduler
cd sinch-fastify-scheduler

2. Initialize Node.js Project:

bash
npm init -y

This creates a package.json file with default settings.

3. Install Dependencies:

We need several packages for our application:

bash
# Production Dependencies
npm install fastify @sinch/sdk-core dotenv @prisma/client node-cron pino @fastify/rate-limit

# Development Dependencies
npm install -D prisma pino-pretty nodemon
  • fastify: The web framework.
  • @sinch/sdk-core: The official Sinch Node.js SDK.
  • dotenv: Loads environment variables from .env.
  • @prisma/client: The Prisma database client.
  • node-cron: The job scheduler.
  • pino: Fastify's default logger.
  • @fastify/rate-limit: Plugin for API rate limiting.
  • prisma: The Prisma CLI (development dependency).
  • pino-pretty: Makes logs readable during development.
  • nodemon: Automatically restarts the server on file changes during development.

4. Configure package.json Scripts:

Open your package.json file and add/modify the scripts section:

json
{
  "name": "sinch-fastify-scheduler",
  "version": "1.0.0",
  "description": "SMS Scheduler using Fastify, Prisma, and Sinch",
  "main": "server.js",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js | pino-pretty",
    "test": "echo \"Error: no test specified\" && exit 1",
    "prisma:migrate:dev": "prisma migrate dev",
    "prisma:generate": "prisma generate",
    "prisma:deploy": "prisma migrate deploy"
  },
  "keywords": [
    "sinch",
    "fastify",
    "prisma",
    "sms",
    "scheduler"
  ],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@fastify/rate-limit": "^9.1.0",
    "@prisma/client": "^5.14.0",
    "@sinch/sdk-core": "^1.1.1",
    "dotenv": "^16.4.5",
    "fastify": "^4.27.0",
    "node-cron": "^3.0.3",
    "pino": "^9.1.0"
  },
  "devDependencies": {
    "nodemon": "^3.1.2",
    "pino-pretty": "^11.1.0",
    "prisma": "^5.14.0"
  }
}
  • start: Runs the application in production.
  • dev: Runs the application using nodemon for auto-reloading and pipes logs through pino-pretty.
  • prisma:migrate:dev: Creates and applies database migrations during development.
  • prisma:generate: Generates the Prisma Client based on your schema.
  • prisma:deploy: Applies pending migrations in production environments.
  • "type": "module": Allows us to use import/export syntax instead of require.

5. Initialize Prisma:

Set up Prisma to manage our database connection and schema.

bash
npx prisma init --datasource-provider postgresql

This command does two things:

  • Creates a prisma directory with a basic schema.prisma file.
  • Creates a .env file (if it doesn't exist) and adds a DATABASE_URL placeholder.

6. Configure Environment Variables (.env):

Open the .env file created by Prisma (or create one if it doesn't exist) and add the following variables. Never commit this file to version control.

dotenv
# .env

# Database Connection (Prisma)
# Example: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
DATABASE_URL="postgresql://your_db_user:your_db_password@localhost:5432/sms_scheduler?schema=public"

# Sinch API Credentials
SINCH_PROJECT_ID="YOUR_SINCH_PROJECT_ID"
SINCH_KEY_ID="YOUR_SINCH_KEY_ID"
SINCH_KEY_SECRET="YOUR_SINCH_KEY_SECRET"
SINCH_NUMBER="YOUR_SINCH_NUMBER_OR_SERVICE_PLAN_ID" # e.g., +1234567890 or a Service Plan ID

# Application Settings
PORT=3000
NODE_ENV=development # change to 'production' for deployment
CRON_SCHEDULE="* * * * *" # Run scheduler every minute. Adjust as needed.
ENABLE_SCHEDULER=true # Set to false to disable scheduler start
  • DATABASE_URL: Replace the placeholder with your actual PostgreSQL connection string.
  • SINCH_PROJECT_ID, SINCH_KEY_ID, SINCH_KEY_SECRET: Obtain these from your Sinch Customer Dashboard under Access Keys. Navigate to your Sinch account > APIs > Access Keys. Create a new key if needed.
  • SINCH_NUMBER: This is the phone number or Service Plan ID you will send SMS from. You can find assigned numbers or create Service Plan IDs in your Sinch Customer Dashboard. For SMS, navigate to Numbers > Your Numbers or SMS > Service Plans.
  • PORT: The port your Fastify server will listen on.
  • NODE_ENV: Controls application behavior (e.g., logging).
  • CRON_SCHEDULE: Defines how often the scheduler job runs. * * * * * means every minute.
  • ENABLE_SCHEDULER: Allows disabling the scheduler via environment variable.

7. Create Project Structure:

Organize your code for better maintainability. Create the following directories:

bash
mkdir src
mkdir src/routes
mkdir src/services
mkdir src/jobs
  • src/: Contains the main application code.
  • src/routes/: Holds API route definitions.
  • src/services/: Contains modules for interacting with external services (like Sinch) or database logic.
  • src/jobs/: Contains background job definitions (like the scheduler).

Your project structure should now look similar to this:

text
sinch-fastify-scheduler/
├── node_modules/
├── prisma/
│   ├── migrations/
│   └── schema.prisma
├── src/
│   ├── jobs/
│   ├── routes/
│   └── services/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
└── server.js  (We will create this later)

Ensure .env and node_modules/ are listed in your .gitignore file.


2. Creating a Database Schema and Data Layer

We'll use Prisma to define our database schema and interact with the database.

1. Define Prisma Schema:

Open prisma/schema.prisma and define the model for storing reminder information.

prisma
// prisma/schema.prisma

generator client {
  provider = ""prisma-client-js""
}

datasource db {
  provider = ""postgresql""
  url      = env(""DATABASE_URL"")
}

model Reminder {
  id          String   @id @default(cuid()) // Unique identifier
  phoneNumber String   // Recipient's phone number (E.164 format recommended)
  message     String   // The SMS message content
  scheduledAt DateTime // The date and time the SMS should be sent (UTC)
  status      Status   @default(PENDING) // Current status of the reminder
  sentAt      DateTime? // Timestamp when the SMS was actually sent (or attempted)
  error       String?  // Stores error message if sending failed
  createdAt   DateTime @default(now()) // Timestamp when the reminder was created
  updatedAt   DateTime @updatedAt // Timestamp of the last update

  @@index([status, scheduledAt]) // Index for efficient querying by the scheduler
}

enum Status {
  PENDING // Reminder is scheduled but not yet sent
  SENT    // Reminder SMS was successfully sent
  FAILED  // Attempted to send SMS, but it failed
}
  • id: Unique identifier using CUID.
  • phoneNumber: Stores the recipient's number. Store in E.164 format (e.g., +14155552671) for consistency.
  • message: The text of the SMS.
  • scheduledAt: The core field for scheduling. Store this in UTC to avoid timezone issues. The application logic should handle conversion if necessary.
  • status: Tracks the reminder's state using a Prisma enum. Defaults to PENDING.
  • sentAt: Records when the message was processed.
  • error: Stores details if sending fails.
  • createdAt, updatedAt: Standard timestamp fields.
  • @@index([status, scheduledAt]): Crucial for performance. The scheduler job will query based on these fields, so an index significantly speeds this up.

2. Apply Database Migration:

Run the Prisma migrate command to create the initial migration files and apply the changes to your database.

bash
npx prisma migrate dev --name init
  • Prisma will introspect the schema.
  • It will generate SQL migration files in the prisma/migrations/ directory.
  • It will apply these migrations to your database specified in DATABASE_URL.
  • It will also run prisma generate automatically.

3. Initialize Prisma Client:

Create a reusable Prisma Client instance. Create src/services/prisma.js:

javascript
// src/services/prisma.js
import { PrismaClient } from '@prisma/client';

// Initialize Prisma Client
const prisma = new PrismaClient({
  // Optional: Enable logging for debugging database queries
  // log: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error'],
});

export default prisma;

This centralizes the Prisma Client initialization.


3. Implementing Proper Error Handling, Logging, and Retry Mechanisms

Robust logging and error handling are essential for production applications.

1. Setup Centralized Logger:

Fastify uses Pino for logging. Let's configure it properly. Create src/services/logger.js:

javascript
// src/services/logger.js
import pino from 'pino';
import process from 'node:process';

const isProduction = process.env.NODE_ENV === 'production';

// Configure Pino logger
const logger = pino({
  level: isProduction ? 'info' : 'debug', // Log level based on environment
  transport: isProduction
    ? undefined // Default transport (stdout) in production
    : {
        target: 'pino-pretty', // Pretty print logs in development
        options: {
          colorize: true,
          translateTime: 'SYS:standard',
          ignore: 'pid,hostname',
        },
      },
});

export default logger;
  • Sets log level based on NODE_ENV.
  • Uses pino-pretty only in development for readability. In production, standard JSON logs are usually preferred for log aggregation systems.

2. Integrate Logger with Fastify:

Modify the main server file (server.js, which we'll create fully in Section 7) to use this logger instance. Fastify v4+ uses the logger option passed as an instance.

javascript
// Part of server.js (shown fully later in Section 7)
import Fastify from 'fastify';
import logger from './src/services/logger.js'; // Import our configured logger

const fastify = Fastify({
  logger: logger, // Provide our custom Pino instance
});

3. Error Handling Strategy:

  • API Layer: Fastify has built-in error handling. We'll use route schemas for validation errors. For other errors, we can use try...catch in handlers or Fastify's setErrorHandler.
  • Service Layer (Sinch, Prisma): Functions should catch specific errors (e.g., database connection errors, Sinch API errors) and either handle them gracefully (e.g., return an error object) or re-throw them to be caught higher up. Log errors with context.
  • Scheduler Job: The job needs robust try...catch around its main logic (fetching reminders, sending SMS) to prevent the entire scheduler process from crashing due to a single failed reminder. Log errors and update the reminder status to FAILED.

4. Retry Mechanisms (Conceptual):

Directly implementing complex retry logic with exponential backoff can add significant complexity. For this guide, we'll keep it simple:

  • Scheduler: If sending an SMS fails, the status is set to FAILED, and the error is logged. The scheduler will not automatically retry that specific message in the next run.
  • Improvement Ideas (Beyond this guide):
    • Add a retryCount field to the Reminder model.
    • Modify the scheduler to pick up FAILED reminders with retryCount < MAX_RETRIES.
    • Implement exponential backoff logic before retrying.
    • Consider using a dedicated job queue library (like BullMQ) which has built-in retry mechanisms.

Example Error Logging (in sendSms):

We will log errors with context in src/services/sinch.js (defined in the next section).


4. Integrating with Sinch

Now, let's create a service module to handle interactions with the Sinch SMS API.

1. Create Sinch Service Module:

Create src/services/sinch.js:

javascript
// src/services/sinch.js
import SinchClient from '@sinch/sdk-core';
import process from 'node:process';
import logger from './logger.js'; // Import the logger we just defined

// Validate essential environment variables
const requiredEnv = ['SINCH_PROJECT_ID', 'SINCH_KEY_ID', 'SINCH_KEY_SECRET', 'SINCH_NUMBER'];
for (const variable of requiredEnv) {
  if (!process.env[variable]) {
    // Log fatal error and exit if critical config is missing during initialization
    logger.fatal(`Missing required environment variable for Sinch Service: ${variable}`);
    process.exit(1);
  }
}

const sinchClient = new SinchClient({
  projectId: process.env.SINCH_PROJECT_ID,
  keyId: process.env.SINCH_KEY_ID,
  keySecret: process.env.SINCH_KEY_SECRET,
});

const sinchNumber = process.env.SINCH_NUMBER;

/**
 * Sends an SMS message using the Sinch API.
 * @param {string} to - The recipient phone number (E.164 format).
 * @param {string} body - The message content.
 * @returns {Promise<{success: boolean, messageId?: string, error?: string}>} - Result object
 */
export const sendSms = async (to, body) => {
  if (!to || !body) {
    logger.error({ to, body }, 'Sinch Service: Missing recipient or message body');
    return { success: false, error: 'Missing recipient or message body' };
  }

  // Basic validation for E.164 format (adjust regex as needed for stricter validation)
  if (!/^\+[1-9]\d{1,14}$/.test(to)) {
     logger.warn({ phoneNumber: to }, 'Sinch Service: Potentially invalid phone number format. Sending anyway.');
     // Decide whether to throw an error or attempt sending
     // return { success: false, error: 'Invalid E.164 phone number format' };
  }

  logger.info({ to, from: sinchNumber }, 'Sinch Service: Attempting to send SMS');

  try {
    // Use the SMS API provided by the initialized Sinch client
    // Note: Depending on SDK version, the exact method might differ slightly.
    // Consult the @sinch/sdk-core documentation for the most current API.
    // This example uses `sms.batches.send`.
    const response = await sinchClient.sms.batches.send({
      to: [to], // Expects an array of recipients
      from: sinchNumber,
      body: body,
      // Optional parameters: delivery_report, expire_at, callback_url etc.
    });

    // Assuming the response structure includes an ID for successful sends.
    // Adjust based on the actual SDK response.
    if (response && response.id) {
      logger.info({ messageId: response.id, to }, 'Sinch Service: SMS sent successfully');
      return { success: true, messageId: response.id };
    } else {
      // Handle cases where the API might return a 2xx status but indicate failure differently
      logger.error({ response, to }, 'Sinch Service: SMS sending failed - Unexpected response format');
      return { success: false, error: 'Unexpected Sinch API response format' };
    }

  } catch (error) {
    logger.error({ error: error.message, stack: error.stack, to }, 'Sinch Service: Error sending SMS via Sinch API');
    // You might want to check error.response?.data for more specific Sinch error codes/messages
    const errorMessage = error.response?.data?.message || error.message || 'Unknown Sinch API error';
    return { success: false, error: errorMessage };
  }
};
  • Environment Variable Validation: Checks if crucial Sinch credentials are set before initializing. Exits if missing.
  • Client Initialization: Creates the SinchClient instance using credentials from .env.
  • sendSms Function:
    • Takes the recipient (to) and message (body) as arguments.
    • Includes basic input validation. Adds a basic E.164 format check.
    • Calls the Sinch SDK's SMS sending method (sms.batches.send). Note: Always refer to the official @sinch/sdk-core documentation for the specific version you are using.
    • Uses a try...catch block for error handling.
    • Logs success or failure using the imported logger.
    • Returns a structured object indicating success/failure and including the message ID or error details.

5. Building the API Layer

Let's create the Fastify API endpoint to schedule new reminders.

1. Define API Route:

Create src/routes/reminders.js:

javascript
// src/routes/reminders.js
import prisma from '../services/prisma.js';
import logger from '../services/logger.js';

// Define JSON Schema for request validation
const createReminderSchema = {
  // Fastify v4 style schema attachment
  body: {
    type: 'object',
    required: ['phoneNumber', 'message', 'scheduledAt'],
    properties: {
      phoneNumber: {
        type: 'string',
        description: "Recipient's phone number in E.164 format (e.g., +14155552671)",
        // Example basic pattern - consider more robust validation
        pattern: '^\\+[1-9]\\d{1,14}$'
      },
      message: {
        type: 'string',
        minLength: 1,
        maxLength: 1600, // Sinch SMS length limits vary, check documentation
        description: 'The content of the SMS message',
      },
      scheduledAt: {
        type: 'string',
        format: 'date-time', // ISO 8601 format (e.g., "2025-12-31T18:30:00Z")
        description: 'The scheduled time in UTC (ISO 8601 format)',
      },
    },
    additionalProperties: false,
  },
  response: {
    201: { // Success response
      description: 'Reminder scheduled successfully',
      type: 'object',
      properties: {
        id: { type: 'string' },
        phoneNumber: { type: 'string' },
        message: { type: 'string' },
        scheduledAt: { type: 'string', format: 'date-time' },
        status: { type: 'string', enum: ['PENDING'] }, // Only pending on creation
        createdAt: { type: 'string', format: 'date-time' },
      },
    },
    // Add definitions for error responses (400, 500) if desired
    // 400: { ... }, 500: { ... }
  },
};

/**
 * Route handler plugin for /reminders endpoint
 * @param {import('fastify').FastifyInstance} fastify
 * @param {object} options
 */
async function reminderRoutes(fastify, options) {
  fastify.post(
    '/reminders',
    { schema: createReminderSchema }, // Attach the schema for validation and serialization
    async (request, reply) => {
      const { phoneNumber, message, scheduledAt } = request.body;

      // Basic check: Ensure scheduled time is in the future
      const scheduledTime = new Date(scheduledAt);
      if (isNaN(scheduledTime.getTime())) {
          logger.warn({ scheduledAt }, 'Invalid date format received for scheduledAt');
          reply.code(400); // Bad Request
          return { error: 'Invalid scheduledAt date format. Please use ISO 8601 format (UTC).' };
      }
      if (scheduledTime <= new Date()) {
        logger.warn({ scheduledAt }, 'Attempted to schedule reminder in the past');
        reply.code(400); // Bad Request
        return { error: 'Scheduled time must be in the future.' };
      }

      try {
        logger.info({ phoneNumber, scheduledAt }, 'Creating new reminder');

        const newReminder = await prisma.reminder.create({
          data: {
            phoneNumber,
            message,
            scheduledAt: scheduledTime, // Store as Date object (Prisma handles conversion)
            // status defaults to PENDING
          },
        });

        logger.info({ reminderId: newReminder.id }, 'Reminder created successfully');
        reply.code(201); // Created
        // Return only specific fields defined in the 201 response schema
        // Fastify handles serialization based on the schema
        return {
           id: newReminder.id,
           phoneNumber: newReminder.phoneNumber,
           message: newReminder.message,
           scheduledAt: newReminder.scheduledAt.toISOString(), // Convert back to ISO string for response
           status: newReminder.status,
           createdAt: newReminder.createdAt.toISOString(),
        };

      } catch (error) {
        logger.error({ error: error.message, stack: error.stack }, 'Error creating reminder in database');
        // Check for specific Prisma errors if needed (e.g., unique constraint violation)
        reply.code(500); // Internal Server Error
        return { error: 'Failed to schedule reminder due to a server error.' };
      }
    }
  );
}

export default reminderRoutes;
  • Schema Definition: Defines the expected structure and types for the request body and the successful response (status 201). Includes validation rules (required fields, string formats, date-time format, phone number pattern). Corrected regex pattern.
  • Route Registration: Defines a POST /reminders route using fastify.post.
  • Schema Attachment: Attaches createReminderSchema to the route options. Fastify automatically validates the incoming request body and serializes the response according to the schema.
  • Input Validation: Adds checks for valid date format and ensures scheduledAt is in the future.
  • Database Interaction: Uses the imported prisma client to create a new reminder record.
  • Error Handling: Includes a try...catch block to handle potential database errors during creation.
  • Response: Returns a 201 Created status with the newly created reminder's details (filtered by the response schema). Returns appropriate error codes (400, 500) on failure.

6. Implementing Core Functionality (Scheduler Job)

Now, let's create the background job that checks for due reminders and triggers the SMS sending.

1. Create Scheduler Job:

Create src/jobs/scheduler.js:

javascript
// src/jobs/scheduler.js
import cron from 'node-cron';
import process from 'node:process';
import prisma from '../services/prisma.js';
import { sendSms } from '../services/sinch.js';
import logger from '../services/logger.js';

const cronSchedule = process.env.CRON_SCHEDULE || '* * * * *'; // Default to every minute if not set
let isJobRunning = false; // Simple lock to prevent overlaps

/**
 * Fetches pending reminders that are due and attempts to send them.
 */
async function processDueReminders() {
  if (isJobRunning) {
    logger.warn('Scheduler job already running, skipping this cycle.');
    return;
  }

  isJobRunning = true;
  logger.info('Scheduler job started: Checking for due reminders...');

  const now = new Date();
  let remindersProcessed = 0;
  let remindersFailed = 0;

  try {
    // Find reminders that are PENDING and scheduled for now or in the past
    const dueReminders = await prisma.reminder.findMany({
      where: {
        status: 'PENDING',
        scheduledAt: {
          lte: now, // Less than or equal to the current time
        },
      },
      take: 100, // Process in batches to avoid overwhelming resources/APIs
      orderBy: {
        scheduledAt: 'asc', // Process older ones first
      },
    });

    if (dueReminders.length === 0) {
      logger.info('Scheduler job: No due reminders found.');
      // No return here, proceed to finally block to release lock
    } else {
      logger.info(`Scheduler job: Found ${dueReminders.length} due reminders.`);

      // Process each reminder
      for (const reminder of dueReminders) {
        logger.debug({ reminderId: reminder.id }, 'Processing reminder...');
        let updateData = {};

        try {
          const result = await sendSms(reminder.phoneNumber, reminder.message);

          if (result.success) {
            logger.info({ reminderId: reminder.id, messageId: result.messageId }, 'SMS sent successfully via Sinch.');
            updateData = {
              status: 'SENT',
              sentAt: new Date(),
              error: null, // Clear any previous error
            };
            remindersProcessed++;
          } else {
            logger.error({ reminderId: reminder.id, error: result.error }, 'Failed to send SMS via Sinch.');
            updateData = {
              status: 'FAILED',
              sentAt: new Date(), // Record attempt time
              error: result.error?.substring(0, 500) || 'Unknown Sinch failure', // Store error message (truncated)
            };
            remindersFailed++;
          }
        } catch (sendError) {
          // Catch errors within the sendSms call itself (less likely with current structure, but good practice)
          logger.error({ reminderId: reminder.id, error: sendError.message, stack: sendError.stack }, 'Unexpected error during SMS sending process.');
          updateData = {
            status: 'FAILED',
            sentAt: new Date(),
            error: sendError.message?.substring(0, 500) || 'Unexpected processing error',
          };
          remindersFailed++;
        }

        // Update reminder status in the database
        try {
          await prisma.reminder.update({
            where: { id: reminder.id },
            data: updateData,
          });
          logger.debug({ reminderId: reminder.id, status: updateData.status }, 'Reminder status updated.');
        } catch (dbError) {
          // Log DB error but continue processing other reminders
          logger.error({ reminderId: reminder.id, error: dbError.message, stack: dbError.stack }, 'Failed to update reminder status in database.');
           // Depending on strategy, you might want to retry this DB update later
        }
      } // End of loop
    }

  } catch (error) {
    // Catch errors related to fetching reminders from DB
    logger.error({ error: error.message, stack: error.stack }, 'Scheduler job failed: Error fetching due reminders.');
  } finally {
    isJobRunning = false; // Release the lock
    logger.info(`Scheduler job finished. Processed: ${remindersProcessed}, Failed: ${remindersFailed}`);
  }
}

/**
 * Initializes and starts the cron job.
 */
export function startScheduler() {
  if (!cron.validate(cronSchedule)) {
    logger.error(`Invalid CRON_SCHEDULE: ${cronSchedule}. Scheduler not started.`);
    return;
  }

  logger.info(`Starting scheduler with schedule: ${cronSchedule}`);
  // Schedule the job
  cron.schedule(cronSchedule, processDueReminders, {
     scheduled: true,
     timezone: ""UTC"" // Explicitly run cron based on UTC
  });

  // Optional: Run once immediately on startup for testing or catching up
  // logger.info('Running scheduler once on startup...');
  // processDueReminders();
}
  • Cron Initialization: Uses node-cron to schedule the processDueReminders function based on CRON_SCHEDULE from .env. Runs in UTC.
  • Concurrency Lock: Uses a simple isJobRunning flag. Note: This simple flag prevents overlap on a single instance but does not handle concurrency across multiple application instances in a distributed environment. More robust solutions like database advisory locks or dedicated job queues (e.g., BullMQ) are needed for multi-instance deployments.
  • Fetch Due Reminders: Queries the database using Prisma for PENDING reminders where scheduledAt is less than or equal to the current time (now). Uses take for batching and orderBy.
  • Process Each Reminder: Iterates through reminders, calls sendSms, determines updateData, and updates the reminder status (SENT or FAILED) in the database.
  • Robust Error Handling: Includes try...catch blocks around DB query, individual SMS sending, and individual DB updates. Uses finally to ensure the lock is released.
  • Logging: Logs the start, end, progress, successes, and failures of the job.

7. Server Setup (server.js)

We need the main entry point to tie everything together. Create server.js in the root directory:

javascript
// server.js
import Fastify from 'fastify';
import dotenv from 'dotenv';
import process from 'node:process';

// Load environment variables from .env file
dotenv.config();

import logger from './src/services/logger.js'; // Import our configured logger
import prisma from './src/services/prisma.js'; // Import prisma client (though not directly used here, good to have access)
import reminderRoutes from './src/routes/reminders.js';
import { startScheduler } from './src/jobs/scheduler.js';
import rateLimit from '@fastify/rate-limit';

const isProduction = process.env.NODE_ENV === 'production';
const port = parseInt(process.env.PORT || '3000', 10);
const enableScheduler = process.env.ENABLE_SCHEDULER === 'true';

// Initialize Fastify with our custom logger
const fastify = Fastify({
  logger: logger,
});

// Register plugins
// Rate Limiting (optional but recommended)
await fastify.register(rateLimit, {
  max: 100, // max requests per window
  timeWindow: '1 minute'
});

// Register API routes
await fastify.register(reminderRoutes, { prefix: '/api' }); // Prefix routes with /api

// Graceful shutdown handler
const startGracefulShutdown = async () => {
  logger.info('Initiating graceful shutdown...');
  try {
    // Stop accepting new connections
    await fastify.close();
    logger.info('Fastify server closed.');
    // Disconnect Prisma client
    await prisma.$disconnect();
    logger.info('Prisma client disconnected.');
    // Add any other cleanup logic here (e.g., stop cron jobs if needed, though node-cron usually stops with the process)
    process.exit(0);
  } catch (err) {
    logger.error(err, 'Error during graceful shutdown');
    process.exit(1);
  }
};

process.on('SIGINT', startGracefulShutdown); // Handle Ctrl+C
process.on('SIGTERM', startGracefulShutdown); // Handle kill commands

// Start the server
const startServer = async () => {
  try {
    await fastify.listen({ port: port, host: '0.0.0.0' }); // Listen on all available network interfaces
    logger.info(`Server listening on port ${port}`);

    // Start the scheduler job if enabled
    if (enableScheduler) {
      startScheduler();
    } else {
      logger.warn('Scheduler is disabled via ENABLE_SCHEDULER environment variable.');
    }

  } catch (err) {
    fastify.log.error(err);
    process.exit(1);
  }
};

// Run the server start function
startServer();
  • Environment Variables: Loads .env using dotenv.
  • Imports: Imports Fastify, logger, Prisma, routes, scheduler, and rate-limit plugin.
  • Fastify Instance: Creates the Fastify instance with the configured logger.
  • Plugin Registration: Registers the @fastify/rate-limit plugin and the reminderRoutes (prefixing them with /api). Uses await for registration as it can be asynchronous.
  • Graceful Shutdown: Implements handlers for SIGINT and SIGTERM signals to close the server and disconnect Prisma properly before exiting.
  • Server Start: Defines an async function startServer to listen on the configured port and host (0.0.0.0 makes it accessible externally, adjust if needed).
  • Scheduler Start: Calls startScheduler() only if ENABLE_SCHEDULER is true.
  • Error Handling: Includes try...catch around fastify.listen to handle startup errors.

Frequently Asked Questions

How to schedule SMS reminders using Node.js?

You can schedule SMS reminders using Node.js by building an application with Fastify, Prisma, and the Sinch SMS API. The application stores reminder details in a PostgreSQL database and uses node-cron to trigger messages via Sinch at the specified time. This setup ensures reliable delivery of time-sensitive communications.

What is the Sinch SMS API used for in this project?

The Sinch SMS API is used to send the actual SMS messages to recipients. The application integrates with Sinch via the @sinch/sdk-core package, allowing you to send messages globally. This simplifies the process of sending SMS messages from your Node.js application.

Why use Fastify for building a Node.js SMS scheduler?

Fastify is a high-performance web framework known for its speed and extensible plugin architecture. Its efficiency makes it ideal for handling API requests and integrating with external services like the Sinch SMS API and PostgreSQL database.

When should I use a production-ready SMS scheduler like this one?

A production-ready SMS scheduler is ideal when you need reliable, automated delivery of SMS messages at specific times. Use cases include appointment reminders, notifications, marketing campaigns, and other time-sensitive communications requiring accurate scheduling and error management.

What database is used to store SMS reminders in this tutorial?

The tutorial utilizes PostgreSQL, a powerful and reliable open-source relational database, to store the SMS reminder details. This is combined with Prisma, an ORM that simplifies database interactions and ensures type safety within your Node.js application.

How to set up a Sinch SMS scheduler with Node.js?

The tutorial provides step-by-step instructions for setting up a Sinch SMS scheduler with Node.js. This involves installing necessary packages like Fastify, @sinch/sdk-core, and Prisma, configuring environment variables with your Sinch credentials, setting up the database schema, and creating the scheduler job.

What is Prisma used for in this Node.js project?

Prisma is used as an Object-Relational Mapper (ORM) to simplify interactions with the PostgreSQL database. It streamlines database access, schema migrations, and provides type safety, making database operations cleaner and more efficient.

Why is node-cron important for the SMS scheduler?

Node-cron is a task scheduler that enables the application to periodically check the database for due reminders. It's crucial for the automation aspect of the project, as it ensures reminders are sent at the correct scheduled times.

How does error handling work in this SMS scheduler application?

Error handling is implemented throughout the application using try...catch blocks and strategic logging. This includes handling potential errors during API requests, database interactions, and SMS sending via Sinch, ensuring the application remains stable and informative during issues.

Can I modify the frequency of the SMS scheduler?

Yes, you can modify the scheduler's frequency by changing the CRON_SCHEDULE environment variable. The default value is '* * * * *', which means the scheduler runs every minute, but you can customize it using standard cron syntax.

How to handle retries for failed SMS messages in this setup?

While the provided example doesn't include automatic retries, you can implement this by adding a retryCount field to the database schema and modifying the scheduler to check and retry failed messages up to a maximum retry limit. For advanced retry logic, consider using a dedicated job queue library.

What Node.js version is required for the Sinch SMS scheduler?

Node.js version 20.0.0 or later is required for this project. This is primarily because Fastify v5, a key dependency used as the web framework, necessitates Node.js 20 or higher to work properly.

What's the role of pino and pino-pretty in this project?

Pino is the logger used by Fastify, offering efficient structured logging capabilities. Pino-pretty is a development dependency that formats Pino's JSON output into a human-readable format, making debugging easier. In production, pino is usually configured for JSON output compatible with logging systems.

How to configure Sinch credentials for this application?

Sinch credentials (Project ID, Key ID, and Key Secret) should be stored in a .env file in your project's root directory. Never commit this file to version control. The application loads these credentials from the .env file during startup. Obtain your credentials from your Sinch Customer Dashboard under Access Keys and Numbers > Your Numbers or SMS > Service Plans for the Sinch number.