code examples

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

Build a Scalable Reminder System with Node.js, Express, AWS SNS, and EventBridge Scheduler

A step-by-step guide to creating a production-ready scheduled reminder system using Node.js, Express, AWS SNS, and EventBridge Scheduler, leveraging AWS managed services for scalability and reliability.

This guide provides a step-by-step walkthrough for building a production-ready scheduled reminder system using Node.js, Express, AWS Simple Notification Service (SNS), and AWS EventBridge Scheduler. You'll learn how to create, manage, and automatically trigger notifications based on user-defined schedules.

This system solves the common problem of needing to reliably send messages (like reminders, notifications, or task triggers) at specific future times or recurring intervals without managing complex cron jobs or stateful scheduler instances. By leveraging AWS managed services, we achieve scalability, reliability, and cost-effectiveness.

We chose Node.js and Express for their popularity and ease of building APIs, AWS SNS for its flexible pub/sub messaging capabilities, and AWS EventBridge Scheduler for its precise, serverless scheduling features. While this guide uses PostgreSQL for the database examples, the concepts and ORM (Prisma) can be adapted for other databases like MySQL, SQLite, or SQL Server, though specific setup steps might vary.

Final Outcome: A RESTful API built with Express that allows creating and deleting scheduled messages. These messages will be automatically published to an AWS SNS topic at the specified time via EventBridge Scheduler. Subscribers to the SNS topic (e.g., email, SMS, Lambda) will then receive the message.

Prerequisites:

  • Node.js (v18 or later recommended) and npm/yarn installed.
  • An AWS account with appropriate permissions to manage IAM, SNS, and EventBridge Scheduler.
  • AWS CLI installed and configured (optional but helpful for verification).
  • Basic understanding of JavaScript, Node.js, Express, and REST APIs.
  • A text editor or IDE (like VS Code).
  • Docker installed (for containerization section).
  • A PostgreSQL database instance (or adapt Prisma setup for another supported database).

System Architecture

Here's a high-level overview of how the components interact:

mermaid
graph LR
    A[User/Client] -- 1. API Request (POST /schedules) --> B(Express API Server);
    B -- 2. Create Schedule Entry --> C(Database - PostgreSQL/Prisma);
    B -- 3. Create EventBridge Schedule --> D(AWS EventBridge Scheduler);
    D -- 4. Trigger at Scheduled Time --> E(AWS SNS Topic);
    E -- 5. Publish Message --> F[Subscribers (Email, SMS, Lambda, etc.)];
    A -- 1a. API Request (DELETE /schedules/:id) --> B;
    B -- 2a. Retrieve Schedule Info --> C;
    B -- 3a. Delete EventBridge Schedule --> D;
    B -- 4a. Delete Schedule Entry --> C;

    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 E fill:#f96,stroke:#333,stroke-width:2px
    style F fill:#9cf,stroke:#333,stroke-width:2px
  1. A client sends a request to the Express API to create a schedule (message, target time/cron, SNS topic).
  2. The API saves the schedule details to a database (using Prisma).
  3. The API creates a corresponding schedule in AWS EventBridge Scheduler, configuring it to target the specified SNS topic with the message payload.
  4. At the scheduled time, EventBridge Scheduler triggers and sends the payload to the SNS topic.
  5. SNS delivers the message to all subscribed endpoints. (1a-4a) Deleting a schedule involves removing it from EventBridge and the database via the API.

1. Setting up the Project

Let's initialize our Node.js project and install necessary dependencies.

  1. Create Project Directory: Open your terminal and create a new directory for the project.

    bash
    mkdir node-sns-scheduler
    cd node-sns-scheduler
  2. Initialize Node.js Project:

    bash
    npm init -y

    This creates a package.json file.

  3. Install Dependencies: We need Express for the API, the AWS SDK v3 for interacting with AWS services, Prisma for database interaction, dotenv for environment variables, and uuid for generating unique IDs.

    bash
    npm install express @aws-sdk/client-sns @aws-sdk/client-scheduler @prisma/client dotenv uuid
    npm install --save-dev prisma nodemon
    • express: Web framework.
    • @aws-sdk/client-sns: AWS SDK v3 client for SNS.
    • @aws-sdk/client-scheduler: AWS SDK v3 client for EventBridge Scheduler.
    • @prisma/client: Prisma's database client.
    • dotenv: Loads environment variables from a .env file.
    • uuid: Generates unique identifiers for schedule names.
    • prisma (dev): ORM toolkit for database management.
    • nodemon (dev): Utility to automatically restart the server during development.
  4. Configure package.json Scripts: Add scripts for starting the server and running Prisma commands.

    json
    // package.json
    {
      // ... other configurations
      ""main"": ""src/server.js"", // Point main to our server file
      ""scripts"": {
        ""start"": ""node src/server.js"",
        ""dev"": ""nodemon src/server.js"", // Use nodemon for development
        ""test"": ""echo \""Error: no test specified\"" && exit 1"",
        ""prisma:migrate"": ""prisma migrate dev"",
        ""prisma:generate"": ""prisma generate""
      },
      // ... dependencies
      ""prisma"": {
        ""schema"": ""prisma/schema.prisma""
      }
    }
  5. Set up Project Structure: Create the following directory structure for better organization:

    node-sns-scheduler/ ├── prisma/ │ └── schema.prisma (Will be created by Prisma init) ├── src/ │ ├── controllers/ │ │ └── scheduleController.js │ ├── routes/ │ │ └── scheduleRoutes.js │ ├── services/ │ │ ├── awsService.js │ │ └── databaseService.js │ ├── utils/ │ │ └── logger.js (Optional: For better logging) │ ├── config/ │ │ └── index.js (For central config loading) │ └── server.js (Main Express app) ├── .env (For environment variables - DO NOT COMMIT) ├── .gitignore └── package.json
  6. Create .gitignore: Prevent sensitive files and unnecessary directories from being committed to version control.

    text
    # .gitignore
    node_modules
    .env
    dist
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
  7. Initialize Prisma: Set up Prisma with PostgreSQL (you can change postgresql to mysql, sqlite, sqlserver, or mongodb if needed, adapting the DATABASE_URL format accordingly).

    bash
    npx prisma init --datasource-provider postgresql

    This creates the prisma/ directory and a schema.prisma file, and updates .env with a placeholder DATABASE_URL.

  8. Configure Environment Variables (.env): Create a .env file in the project root. Crucially, you MUST replace the YOUR_... placeholder values below with your actual AWS credentials, region, database connection string, SNS Topic ARN, and EventBridge Role ARN obtained in the following AWS setup steps.

    dotenv
    # .env
    
    # AWS Credentials (Obtain from IAM User setup - Step 2)
    # IMPORTANT: Never commit these directly. Use secure methods in production.
    # Replace these placeholders with actual values obtained in subsequent steps!
    AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
    AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY
    AWS_REGION=us-east-1 # e.g., us-east-1, eu-west-2
    
    # Database Connection (Update with your actual DB connection string)
    # Format: postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public
    DATABASE_URL=""postgresql://postgres:password@localhost:5432/scheduler_db?schema=public""
    
    # SNS Topic ARN (Obtain from SNS Topic creation - Step 3)
    # Replace this placeholder with actual value obtained in subsequent steps!
    SNS_TOPIC_ARN=YOUR_SNS_TOPIC_ARN
    
    # EventBridge Scheduler Execution Role ARN (Obtain from IAM Role creation - Step 4)
    # Replace this placeholder with actual value obtained in subsequent steps!
    EVENTBRIDGE_SCHEDULER_ROLE_ARN=YOUR_EVENTBRIDGE_SCHEDULER_ROLE_ARN
    
    # Server Port
    PORT=3000
    • Purpose: Storing sensitive credentials and configuration outside the codebase. dotenv loads these into process.env.
    • How to obtain values: Details are in the subsequent AWS Setup sections. Do not proceed without replacing the placeholders.

2. Setting up AWS Resources

We need an IAM user for programmatic access, an SNS topic to publish messages to, and an IAM role for EventBridge Scheduler.

  1. Create an IAM User for Programmatic Access: This user's credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) will be used by our Node.js application to interact with AWS services.

    • Navigate to the IAM service in the AWS Management Console.
    • Go to Users and click Create user.
    • Enter a User name (e.g., sns-scheduler-app-user).
    • Select Provide user access to the AWS Management Console - Optional. Choose if you want console access.
    • Select I want to create an IAM user. Configure password settings if needed.
    • Click Next.
    • On the Set permissions page, choose Attach policies directly.
    • Search for and select the following policies:
      • AmazonSNSFullAccess (For simplicity during setup).
      • AmazonEventBridgeSchedulerFullAccess (For simplicity during setup).
      • Security Best Practice: In production, do not use FullAccess policies. Create custom IAM policies granting only the minimum required permissions:
        • For SNS: sns:Publish (potentially restricted to your specific SNS_TOPIC_ARN), sns:ListTopics.
        • For EventBridge Scheduler: scheduler:CreateSchedule, scheduler:DeleteSchedule, scheduler:GetSchedule, scheduler:ListSchedules.
        • You will also need iam:PassRole permission allowing the user to pass the EventBridge execution role (EVENTBRIDGE_SCHEDULER_ROLE_ARN) to the Scheduler service.
    • Click Next.
    • Review the user details and permissions, then click Create user.
    • Crucial: On the success screen, click Download .csv file or copy the Access key ID and Secret access key. You won't be able to see the secret key again.
    • Update your .env file with these credentials (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY).
  2. Create an SNS Topic: This is the destination where EventBridge Scheduler will publish messages.

    • Navigate to the Simple Notification Service (SNS) in the AWS Console.
    • Go to Topics and click Create topic.
    • Select Standard type (FIFO is for ordered messages, not needed here).
    • Enter a Name (e.g., RemindersTopic). A display name is optional.
    • Leave other settings as default for now (Access policy, Encryption, etc.). You can restrict access later if needed.
    • Click Create topic.
    • On the topic details page, copy the ARN (Amazon Resource Name).
    • Update your .env file with this SNS_TOPIC_ARN.
    • Add a Subscription (for testing): To verify messages are sent, add a subscription. Click Create subscription. Select Email as the protocol, enter your email address, and click Create subscription. Check your email and confirm the subscription.
  3. Create an IAM Role for EventBridge Scheduler: EventBridge Scheduler needs permission to publish messages to your SNS topic. It assumes this role when executing a schedule.

    • Navigate back to IAM in the AWS Console.
    • Go to Roles and click Create role.
    • For Trusted entity type, select AWS service.
    • For Use case, search for and select Scheduler. Click Next.
    • On the Add permissions page, search for and select AmazonSNSFullAccess.
      • Crucial for Production: For enhanced security, do not use AmazonSNSFullAccess here in production. Create a more restrictive custom inline policy allowing only the sns:Publish action specifically on your SNS Topic ARN.
      • Example Custom Policy JSON (Attach this directly to the role instead of AmazonSNSFullAccess):
        json
        {
            ""Version"": ""2012-10-17"",
            ""Statement"": [
                {
                    ""Effect"": ""Allow"",
                    ""Action"": ""sns:Publish"",
                    ""Resource"": ""YOUR_SNS_TOPIC_ARN"" // Paste your actual Topic ARN here
                }
            ]
        }
    • Click Next.
    • Enter a Role name (e.g., EventBridgeScheduler-SNS-PublishRole). Add an optional description.
    • Review the role details and trusted entities (should show scheduler.amazonaws.com).
    • Click Create role.
    • Find the newly created role in the list and click on its name.
    • Copy the ARN from the role summary page.
    • Update your .env file with this EVENTBRIDGE_SCHEDULER_ROLE_ARN.

3. Creating the Database Schema and Data Layer

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

  1. Define the Schema (prisma/schema.prisma): Open prisma/schema.prisma and define the model to store schedule information.

    prisma
    // prisma/schema.prisma
    
    generator client {
      provider = ""prisma-client-js""
    }
    
    datasource db {
      provider = ""postgresql"" // Or your chosen provider
      url      = env(""DATABASE_URL"")
    }
    
    // Schedule model
    model Schedule {
      id                  String    @id @default(uuid())
      message             String
      cronExpression      String?   // For recurring schedules (e.g., ""cron(0 10 * * ? *)"")
      scheduledTime       DateTime? // For one-time schedules (ISO 8601 format)
      snsTopicArn         String
      eventBridgeRuleName String    @unique // Name used for the EventBridge Schedule rule
      createdAt           DateTime  @default(now())
      updatedAt           DateTime  @updatedAt
    
      @@index([eventBridgeRuleName])
    }
    • id: Unique identifier for the schedule entry.
    • message: The content of the reminder/notification.
    • cronExpression: Standard cron syntax (e.g., cron(0 18 ? * MON-FRI *)) or rate syntax (e.g., rate(5 minutes)) for recurring schedules. See AWS Schedule Expression Docs. One of cronExpression or scheduledTime must be provided.
    • scheduledTime: An ISO 8601 timestamp (e.g., 2025-12-31T10:00:00Z) for one-time schedules.
    • snsTopicArn: The ARN of the SNS topic to publish to (allows flexibility if you have multiple topics).
    • eventBridgeRuleName: The unique name given to the corresponding EventBridge Scheduler rule. We need this to delete the rule later.
  2. Run Database Migration: Apply the schema changes to your database. Prisma will create the Schedule table.

    bash
    npx prisma migrate dev --name init-schedule-model

    Follow the prompts (it will ask for a migration name if you omit --name). Ensure your database server is running and accessible using the DATABASE_URL in .env.

  3. Generate Prisma Client: Update the Prisma client based on your schema.

    bash
    npx prisma generate
  4. Create Database Service (src/services/databaseService.js): Set up a reusable Prisma client instance.

    javascript
    // src/services/databaseService.js
    const { PrismaClient } = require('@prisma/client');
    
    const prisma = new PrismaClient({
        // Optional: Enable logging for debugging
        // log: ['query', 'info', 'warn', 'error'],
    });
    
    // Graceful shutdown
    process.on('SIGINT', async () => {
        await prisma.$disconnect();
        process.exit(0);
    });
    
    process.on('SIGTERM', async () => {
        await prisma.$disconnect();
        process.exit(0);
    });
    
    module.exports = prisma;

4. Implementing Core Functionality (AWS Service)

Create a service to encapsulate the logic for interacting with AWS SNS and EventBridge Scheduler.

  1. Create AWS Service (src/services/awsService.js):

    javascript
    // src/services/awsService.js
    const { SchedulerClient, CreateScheduleCommand, DeleteScheduleCommand } = require('@aws-sdk/client-scheduler');
    const { SNSClient /*, PublishCommand */ } = require('@aws-sdk/client-sns'); // PublishCommand not needed directly here
    const { v4: uuidv4 } = require('uuid');
    const config = require('../config'); // We'll create this next
    
    // Initialize AWS Clients
    const schedulerClient = new SchedulerClient({ region: config.aws.region });
    const snsClient = new SNSClient({ region: config.aws.region }); // Might be used for other SNS interactions later
    
    /**
     * Creates a schedule in AWS EventBridge Scheduler.
     * @param {object} params
     * @param {string} params.scheduleId - Unique ID for correlation, part of the rule name.
     * @param {string} params.message - The JSON payload string to send to SNS.
     * @param {string} params.snsTopicArn - ARN of the target SNS Topic.
     * @param {string} [params.cronExpression] - Cron expression for recurring schedule (e.g., ""cron(0 10 * * ? *)"").
     * @param {Date} [params.scheduledTime] - Date object for one-time schedule.
     * @param {string} params.schedulerRoleArn - IAM Role ARN for EventBridge Scheduler.
     * @returns {Promise<string>} The unique name of the created EventBridge Schedule rule.
     */
    const createEventBridgeSchedule = async ({
        scheduleId,
        message,
        snsTopicArn,
        cronExpression,
        scheduledTime,
        schedulerRoleArn,
    }) => {
        if (!cronExpression && !scheduledTime) {
            throw new Error('Either cronExpression or scheduledTime must be provided.');
        }
        if (cronExpression && scheduledTime) {
            throw new Error('Provide only cronExpression OR scheduledTime, not both.');
        }
    
        // Generate a unique name for the EventBridge schedule rule
        // Must be unique within the region/account. Using UUID ensures this.
        const ruleName = `schedule-${scheduleId}-${uuidv4()}`;
    
        const scheduleExpression = cronExpression
            ? cronExpression
            // Format Date object to EventBridge 'at' expression: 'at(yyyy-mm-ddThh:mm:ss)'
            // Ensure time is in UTC for consistency, or handle timezones appropriately.
            : `at(${scheduledTime.toISOString().substring(0, 19)})`;
    
        const command = new CreateScheduleCommand({
            Name: ruleName,
            Description: `Scheduled message: ${message.substring(0, 50)}...`,
            ScheduleExpression: scheduleExpression,
            // ScheduleExpressionTimezone: 'America/New_York', // Optional: Specify timezone if cron needs it
            Target: {
                Arn: snsTopicArn,
                RoleArn: schedulerRoleArn,
                Input: message, // Pass the message directly as input to the SNS target
                SnsParameters: { // Optional: If you need specific SNS attributes per message
                    // MessageGroupId: 'your-group-id', // Only for FIFO topics
                    // MessageDeduplicationId: uuidv4() // Only for FIFO topics
                },
                // Optional: Configure retries and dead-letter queue for failures
                RetryPolicy: {
                    MaximumEventAgeInSeconds: 86400, // 24 hours
                    MaximumRetryAttempts: 5, // Sensible default
                },
                // DeadLetterConfig: { // Uncomment and configure if you have an SQS DLQ setup
                //   Arn: 'arn:aws:sqs:REGION:ACCOUNT_ID:your-dlq-name'
                // }
            },
            FlexibleTimeWindow: {
                Mode: 'OFF', // Use 'FLEXIBLE' to allow execution within a window (e.g., Mode: 'FLEXIBLE', MaximumWindowInMinutes: 15)
            },
            State: 'ENABLED', // Create the schedule as active
            ActionAfterCompletion: scheduledTime ? 'DELETE' : 'NONE', // Automatically delete one-time schedules after completion
        });
    
        try {
            console.log(`Creating EventBridge schedule: ${ruleName} with expression: ${scheduleExpression}`);
            const result = await schedulerClient.send(command);
            console.log(`Successfully created EventBridge schedule: ${ruleName}`, result);
            return ruleName; // Return the unique name used for the rule
        } catch (error) {
            console.error(`Error creating EventBridge schedule ${ruleName}:`, error);
            // Consider more specific error handling or mapping
            throw new Error(`Failed to create schedule in EventBridge: ${error.message}`);
        }
    };
    
    /**
     * Deletes a schedule from AWS EventBridge Scheduler.
     * @param {string} ruleName - The unique name of the EventBridge Schedule rule to delete.
     * @returns {Promise<void>}
     */
    const deleteEventBridgeSchedule = async (ruleName) => {
        const command = new DeleteScheduleCommand({
            Name: ruleName,
            // ClientToken: `delete-${ruleName}-${uuidv4()}` // Optional idempotency token
        });
    
        try {
            console.log(`Deleting EventBridge schedule: ${ruleName}`);
            await schedulerClient.send(command);
            console.log(`Successfully deleted EventBridge schedule: ${ruleName}`);
        } catch (error) {
            // Handle specific errors, e.g., if the rule doesn't exist (ResourceNotFoundException)
            if (error.name === 'ResourceNotFoundException') {
                console.warn(`EventBridge schedule ${ruleName} not found. Might have been already deleted or completed (if one-time).`);
                // Optionally ignore this error if deletion is idempotent in your logic
            } else {
                console.error(`Error deleting EventBridge schedule ${ruleName}:`, error);
                throw new Error(`Failed to delete schedule from EventBridge: ${error.message}`);
            }
        }
    };
    
    module.exports = {
        createEventBridgeSchedule,
        deleteEventBridgeSchedule,
    };
    • Why SchedulerClient and SNSClient: These are the dedicated SDK clients for interacting with their respective services.
    • Why CreateScheduleCommand: This SDK command maps directly to the EventBridge CreateSchedule API call.
    • Why Unique ruleName: EventBridge schedule names must be unique within an AWS account and region. Appending a UUID guarantees this. We store this name in our database to delete the correct schedule later.
    • Why scheduleExpression: EventBridge uses cron(...) or at(...) syntax. We convert the input cronExpression or scheduledTime accordingly. at() expects yyyy-mm-ddThh:mm:ss format (typically UTC).
    • Why Target configuration: This tells EventBridge what to invoke (Arn: SNS Topic), how (Input: the message), and with what permissions (RoleArn).
    • Why ActionAfterCompletion: 'DELETE': For one-time schedules (at expressions), this automatically cleans up the EventBridge rule after it runs, preventing clutter.
    • Why DeleteScheduleCommand: Used to remove the schedule from EventBridge when a user deletes it via our API.
    • Error Handling: Includes basic logging and throws errors for upstream handling. Catches ResourceNotFoundException during delete gracefully.
  2. Create Config Loader (src/config/index.js): Load environment variables centrally.

    javascript
    // src/config/index.js
    const dotenv = require('dotenv');
    
    // Load .env file contents into process.env
    dotenv.config();
    
    const config = {
        port: process.env.PORT || 3000,
        aws: {
            accessKeyId: process.env.AWS_ACCESS_KEY_ID,
            secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
            region: process.env.AWS_REGION,
            snsTopicArn: process.env.SNS_TOPIC_ARN,
            schedulerRoleArn: process.env.EVENTBRIDGE_SCHEDULER_ROLE_ARN,
        },
        database: {
            url: process.env.DATABASE_URL,
        },
    };
    
    // Basic validation for required AWS config
    if (!config.aws.accessKeyId || !config.aws.secretAccessKey || !config.aws.region || !config.aws.snsTopicArn || !config.aws.schedulerRoleArn || config.aws.accessKeyId === 'YOUR_AWS_ACCESS_KEY_ID' /* Check for placeholders */) {
        console.error('FATAL ERROR: Missing or placeholder AWS environment variables in .env file. Please provide actual credentials and ARNs.');
        process.exit(1);
    }
    if (!config.database.url || config.database.url.includes('localhost') /* Simple check for default placeholder */) {
        // Warning instead of exit for DB URL if local dev is intended, but still flags potential issue.
        console.warn('WARNING: DATABASE_URL environment variable might be missing or using a default placeholder.');
        if (!config.database.url) {
             console.error('FATAL ERROR: DATABASE_URL is missing.');
             process.exit(1);
        }
    }
    
    module.exports = config;
    • Why dotenv.config(): Loads the .env file.
    • Why Validation: Ensures critical configuration is present and placeholders have likely been replaced at startup.

5. Building the API Layer (Express Routes & Controllers)

Define the API endpoints and the logic to handle requests.

  1. Create Schedule Controller (src/controllers/scheduleController.js):

    javascript
    // src/controllers/scheduleController.js
    const { v4: uuidv4 } = require('uuid');
    const prisma = require('../services/databaseService');
    const awsService = require('../services/awsService');
    const config = require('../config');
    
    /**
     * POST /schedules
     * Creates a new schedule.
     * Body: { message: string, cronExpression?: string, scheduledTime?: string (ISO 8601) }
     */
    exports.createSchedule = async (req, res, next) => {
        const { message, cronExpression, scheduledTime: scheduledTimeString } = req.body;
        const snsTopicArn = config.aws.snsTopicArn; // Use configured topic ARN
        const schedulerRoleArn = config.aws.schedulerRoleArn; // Use configured role ARN
    
        // --- Input Validation ---
        if (!message) {
            return res.status(400).json({ error: 'Message is required.' });
        }
        if (!cronExpression && !scheduledTimeString) {
            return res.status(400).json({ error: 'Either cronExpression or scheduledTime (ISO 8601 format) must be provided.' });
        }
        if (cronExpression && scheduledTimeString) {
            return res.status(400).json({ error: 'Provide only cronExpression OR scheduledTime, not both.' });
        }
    
        let scheduledTime;
        if (scheduledTimeString) {
            scheduledTime = new Date(scheduledTimeString);
            if (isNaN(scheduledTime.getTime())) {
                return res.status(400).json({ error: 'Invalid scheduledTime format. Use ISO 8601 (e.g., 2025-12-31T10:00:00Z).' });
            }
            // Optional: Check if time is in the past.
            // Note: Due to potential clock skew or short delays between validation and EventBridge creation,
            // this might reject times that are *very* slightly in the future.
            // A small buffer (e.g., `scheduledTime <= new Date(Date.now() + 5000)`) could be added,
            // or rely solely on EventBridge's validation which happens upon creation.
            if (scheduledTime <= new Date()) {
                 return res.status(400).json({ error: 'scheduledTime must be in the future.' });
            }
        }
        // Optional: Add validation for cronExpression format if desired
    
        // --- Core Logic ---
        const scheduleId = uuidv4(); // Unique ID for our DB record and part of rule name
        let eventBridgeRuleName; // Define variable in outer scope for potential cleanup
    
        try {
            // 1. Create EventBridge Schedule
            eventBridgeRuleName = await awsService.createEventBridgeSchedule({
                scheduleId,
                message: JSON.stringify({ default: message }), // SNS requires JSON string structure for multi-protocol
                snsTopicArn,
                cronExpression,
                scheduledTime,
                schedulerRoleArn,
            });
    
            // 2. Save schedule details to Database
            const newSchedule = await prisma.schedule.create({
                data: {
                    id: scheduleId,
                    message,
                    cronExpression,
                    scheduledTime, // Store Date object or null
                    snsTopicArn,
                    eventBridgeRuleName, // Store the unique name used in AWS
                },
            });
    
            console.log(`Schedule created successfully in DB and EventBridge: ${scheduleId}`);
            res.status(201).json(newSchedule);
    
        } catch (error) {
            console.error(`Error creating schedule ${scheduleId}:`, error);
    
            // --- Attempt Cleanup if DB Save Failed After AWS Success ---
            // This is a potential inconsistency point. If the EventBridge schedule was created
            // but saving to the database failed, we have an orphaned schedule in AWS.
            // The basic cleanup attempt below might work, but robust production systems
            // often need more sophisticated mechanisms like:
            //   - Storing state flags in the DB (e.g., 'pending_aws_confirmation').
            //   - Using a background job queue for cleanup/reconciliation.
            //   - Implementing idempotency in schedule creation if possible.
            if (eventBridgeRuleName) { // Check if ruleName was generated (meaning AWS call was likely attempted/succeeded)
               console.warn(`Attempting to clean up orphaned EventBridge schedule: ${eventBridgeRuleName} due to DB error.`);
               try {
                   await awsService.deleteEventBridgeSchedule(eventBridgeRuleName);
                   console.log(`Successfully cleaned up orphaned schedule: ${eventBridgeRuleName}`);
               } catch (cleanupError) {
                   // Log cleanup error, manual intervention might be needed
                   console.error(`Failed to clean up orphaned EventBridge schedule ${eventBridgeRuleName}:`, cleanupError);
               }
            }
            // Pass original error to error handling middleware
            next(error);
        }
    };
    
    /**
     * GET /schedules
     * Retrieves all schedules from the database.
     */
    exports.listSchedules = async (req, res, next) => {
        try {
            const schedules = await prisma.schedule.findMany({
                orderBy: { createdAt: 'desc' }
            });
            res.status(200).json(schedules);
        } catch (error) {
            console.error(""Error listing schedules:"", error);
            next(error);
        }
    };
    
    /**
     * DELETE /schedules/:id
     * Deletes a schedule by its database ID.
     */
    exports.deleteSchedule = async (req, res, next) => {
        const { id } = req.params;
    
        try {
            // 1. Find schedule in DB to get the EventBridge rule name
            const schedule = await prisma.schedule.findUnique({
                where: { id },
            });
    
            if (!schedule) {
                return res.status(404).json({ error: 'Schedule not found.' });
            }
    
            // 2. Delete the schedule from EventBridge Scheduler
            // This uses the unique name stored during creation.
            await awsService.deleteEventBridgeSchedule(schedule.eventBridgeRuleName);
    
            // 3. Delete the schedule from the Database
            await prisma.schedule.delete({
                where: { id },
            });
    
            console.log(`Schedule deleted successfully from DB and EventBridge: ${id}`);
            res.status(204).send(); // No content on successful deletion
    
        } catch (error) {
            console.error(`Error deleting schedule ${id}:`, error);
            // Pass error to error handling middleware
            next(error);
        }
    };
    
    // Add other controller methods if needed (e.g., getScheduleById, updateSchedule)
    • Input Validation: Checks for required fields (message), mutually exclusive fields (cronExpression, scheduledTime), and valid formats (ISO 8601 for scheduledTime).
    • Core Logic (createSchedule):
      1. Generates a unique scheduleId.
      2. Calls awsService.createEventBridgeSchedule, passing necessary parameters and ensuring the message is JSON stringified as required by SNS targets in EventBridge.
      3. If successful, saves the schedule details (including the eventBridgeRuleName returned by the AWS service) to the database using Prisma.
      4. Includes basic cleanup logic: If the AWS call succeeds but the database save fails, it attempts to delete the orphaned EventBridge schedule.
    • Core Logic (deleteSchedule):
      1. Finds the schedule in the database using the provided id.
      2. Retrieves the eventBridgeRuleName from the database record.
      3. Calls awsService.deleteEventBridgeSchedule with the retrieved rule name.
      4. Deletes the record from the database.
    • Error Handling: Uses try...catch blocks and passes errors to Express's next function for centralized error handling (which should be implemented in server.js).
  2. Create Schedule Routes (src/routes/scheduleRoutes.js):

    javascript
    // src/routes/scheduleRoutes.js
    const express = require('express');
    const scheduleController = require('../controllers/scheduleController');
    
    const router = express.Router();
    
    // POST /api/schedules - Create a new schedule
    router.post('/', scheduleController.createSchedule);
    
    // GET /api/schedules - List all schedules
    router.get('/', scheduleController.listSchedules);
    
    // DELETE /api/schedules/:id - Delete a specific schedule
    router.delete('/:id', scheduleController.deleteSchedule);
    
    // Add other routes as needed (e.g., GET /:id, PUT /:id)
    
    module.exports = router;
    • Purpose: Defines the API endpoints and maps them to the corresponding controller functions.
  3. Set up Express Server (src/server.js):

    javascript
    // src/server.js
    const express = require('express');
    const config = require('./config');
    const scheduleRoutes = require('./routes/scheduleRoutes');
    const prisma = require('./services/databaseService'); // Import to ensure connection/shutdown logic runs
    
    const app = express();
    
    // --- Middleware ---
    app.use(express.json()); // Parse JSON request bodies
    
    // --- Routes ---
    app.use('/api/schedules', scheduleRoutes); // Mount schedule routes under /api/schedules
    
    // --- Basic Health Check Route ---
    app.get('/health', (req, res) => {
        res.status(200).json({ status: 'UP' });
    });
    
    // --- Global Error Handler ---
    // This should be the last middleware added
    app.use((err, req, res, next) => {
        console.error('Global Error Handler:', err); // Log the error stack
    
        // Default to 500 Internal Server Error if status is not set
        const statusCode = err.statusCode || 500;
        const message = err.message || 'An unexpected error occurred.';
    
        // Send a generic error response
        // Avoid sending detailed error messages/stack traces in production
        res.status(statusCode).json({
            error: {
                message: message,
                // Optionally include error code or type in production if safe/useful
                // type: err.type || 'InternalServerError',
            }
        });
    });
    
    // --- Start Server ---
    const server = app.listen(config.port, () => {
        console.log(`Server running on port ${config.port}`);
        console.log(`AWS Region: ${config.aws.region}`);
        console.log(`Target SNS Topic ARN: ${config.aws.snsTopicArn}`);
    });
    
    // --- Graceful Shutdown ---
    const gracefulShutdown = async (signal) => {
        console.log(`\nReceived ${signal}. Shutting down gracefully...`);
        server.close(async () => {
            console.log('HTTP server closed.');
            try {
                await prisma.$disconnect();
                console.log('Database connection closed.');
            } catch (dbError) {
                console.error('Error closing database connection:', dbError);
            } finally {
                process.exit(0);
            }
        });
    };
    
    process.on('SIGINT', () => gracefulShutdown('SIGINT'));
    process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
    
    // Handle unhandled promise rejections
    process.on('unhandledRejection', (reason, promise) => {
        console.error('Unhandled Rejection at:', promise, 'reason:', reason);
        // Optionally exit or implement more robust error handling
        // gracefulShutdown('unhandledRejection'); // Consider if shutdown is appropriate
    });
    
    // Handle uncaught exceptions
    process.on('uncaughtException', (error) => {
        console.error('Uncaught Exception:', error);
        // It's generally recommended to exit after an uncaught exception
        // as the application state might be corrupted.
        gracefulShutdown('uncaughtException');
    });
    
    module.exports = app; // Export for potential testing
    • Middleware: Uses express.json() to parse incoming request bodies.
    • Routing: Mounts the scheduleRoutes under the /api/schedules path.
    • Error Handling: Implements a basic global error handler middleware to catch errors passed via next(error) from controllers/services and send a standardized JSON error response.
    • Server Start: Listens on the configured port.
    • Graceful Shutdown: Includes handlers for SIGINT and SIGTERM to close the server and database connection properly before exiting. Also includes basic handlers for unhandledRejection and uncaughtException.

6. Running and Testing the Application

  1. Ensure Database is Running: Make sure your PostgreSQL (or chosen database) server is running and accessible via the DATABASE_URL in your .env file.

  2. Run Migrations: If you haven't already, apply the database schema:

    bash
    npx prisma migrate dev
  3. Start the Development Server:

    bash
    npm run dev

    Nodemon will watch for file changes and restart the server automatically.

  4. Test with curl or an API Client (e.g., Postman, Insomnia):

    • Create a One-Time Schedule: Replace YYYY-MM-DDTHH:MM:SSZ with a future UTC time.

      bash
      curl -X POST http://localhost:3000/api/schedules \
           -H ""Content-Type: application/json"" \
           -d '{
                 ""message"": ""Your one-time reminder message!"",
                 ""scheduledTime"": ""YYYY-MM-DDTHH:MM:SSZ""
               }'

      Expected Response (201 Created): JSON object representing the created schedule record from the database. Check your AWS Console (EventBridge -> Schedules) to see the new schedule. Check your subscribed email (or other endpoint) after the scheduled time.

    • Create a Recurring Schedule (e.g., every 5 minutes):

      bash
      curl -X POST http://localhost:3000/api/schedules \
           -H ""Content-Type: application/json"" \
           -d '{
                 ""message"": ""Your recurring reminder (every 5 mins)!"",
                 ""cronExpression"": ""cron(*/5 * * * ? *)""
               }'

      Expected Response (201 Created): JSON object for the created schedule. Check your AWS Console and subscribed endpoint every 5 minutes.

    • List All Schedules:

      bash
      curl http://localhost:3000/api/schedules

      Expected Response (200 OK): JSON array of all schedule objects stored in the database. Note the id of a schedule you want to delete.

    • Delete a Schedule: Replace {SCHEDULE_ID} with the actual id obtained from the list or create response.

      bash
      curl -X DELETE http://localhost:3000/api/schedules/{SCHEDULE_ID}

      Expected Response (204 No Content): No body is returned on success. Check your AWS Console (EventBridge -> Schedules) to confirm the corresponding schedule is gone (or marked for deletion if it was a one-time schedule that already completed). Check the database to confirm the record is deleted.

7. Containerization with Docker (Optional)

Dockerizing the application makes deployment consistent across different environments.

  1. Create Dockerfile:

    dockerfile
    # Dockerfile
    
    # Use an official Node.js runtime as a parent image
    # Choose a version compatible with your code (e.g., 18-alpine for smaller size)
    FROM node:18-alpine AS base
    
    # Set the working directory in the container
    WORKDIR /usr/src/app
    
    # Install Prisma CLI globally in the base stage (optional but can be useful)
    # RUN npm install -g prisma
    
    # --- Build Stage ---
    FROM base AS builder
    WORKDIR /usr/src/app
    
    # Copy package.json and package-lock.json (or yarn.lock)
    COPY package*.json ./
    
    # Install app dependencies
    RUN npm install
    
    # Copy prisma schema
    COPY prisma ./prisma/
    
    # Generate Prisma Client (requires devDependencies installed)
    # Ensure DATABASE_URL is available at build time if needed for generation,
    # or handle generation differently (e.g., in entrypoint).
    # For simplicity here, we assume generation doesn't need live DB access.
    RUN npx prisma generate
    
    # Copy application source code
    COPY . .
    
    # --- Production Stage ---
    FROM base AS production
    WORKDIR /usr/src/app
    
    # Copy only necessary files from builder stage
    COPY package*.json ./
    # Install *only* production dependencies
    RUN npm ci --only=production
    
    # Copy generated Prisma client and runtime files from builder
    COPY --from=builder /usr/src/app/node_modules/.prisma ./node_modules/.prisma
    COPY --from=builder /usr/src/app/node_modules/@prisma/client ./node_modules/@prisma/client
    
    # Copy application code
    COPY --from=builder /usr/src/app/src ./src
    COPY --from=builder /usr/src/app/prisma ./prisma # Keep schema for reference if needed
    
    # Expose the port the app runs on
    EXPOSE 3000
    
    # Define the command to run the application
    # Use node directly for production instead of nodemon
    CMD [ ""node"", ""src/server.js"" ]
  2. Create .dockerignore: Prevent unnecessary files from being copied into the Docker image context.

    dockerignore
    # .dockerignore
    node_modules
    npm-debug.log*
    yarn-debug.log*
    yarn-error.log*
    .env
    dist
    Dockerfile
    .dockerignore
    .git
    .gitignore
    README.md
    # Add any other files/dirs not needed in the image
  3. Build the Docker Image:

    bash
    docker build -t node-sns-scheduler-app .
  4. Run the Docker Container: You need to pass the environment variables from your .env file to the container.

    bash
    docker run -p 3000:3000 --env-file .env --name scheduler-api node-sns-scheduler-app
    • -p 3000:3000: Maps port 3000 on your host to port 3000 in the container.
    • --env-file .env: Loads environment variables from your .env file into the container. Note: This is convenient for local development but not recommended for production secrets. Use secure secret management tools (like AWS Secrets Manager, HashiCorp Vault, or environment variables injected by orchestration platforms) in production.
    • --name scheduler-api: Assigns a name to the running container.

    Now you can access the API at http://localhost:3000 just like before.

Conclusion and Next Steps

You have successfully built a scalable reminder system using Node.js, Express, AWS SNS, and EventBridge Scheduler. This setup provides a robust foundation for handling scheduled tasks and notifications.

Potential Improvements and Further Steps:

  • Enhanced Error Handling: Implement more specific error catching and reporting.
  • Input Validation: Use a library like joi or zod for more robust request body validation.
  • Authentication & Authorization: Secure the API endpoints so only authorized users can create/delete schedules.
  • Update Functionality: Add an endpoint (PUT /api/schedules/:id) to modify existing schedules (requires updating both the database and the EventBridge rule).
  • Timezone Handling: Explicitly handle timezones for scheduledTime and cronExpression if your users are in different timezones. EventBridge Scheduler supports ScheduleExpressionTimezone.
  • Dead Letter Queue (DLQ): Configure a DLQ (e.g., an SQS queue) in EventBridge Scheduler's target configuration to capture failed invocations for debugging.
  • Monitoring & Logging: Integrate more detailed logging (e.g., using Winston) and monitoring (e.g., AWS CloudWatch) to track API performance and errors.
  • Testing: Write unit and integration tests for controllers, services, and potentially API endpoints.
  • Production Deployment: Deploy the containerized application using services like AWS ECS, EKS, or App Runner, ensuring secure secret management.
  • Idempotency: Consider making the create operation idempotent if clients might retry requests, potentially using a client-provided idempotency key.

Frequently Asked Questions

How to schedule reminders with Node.js and AWS?

This involves using Node.js, Express for building a RESTful API, AWS SNS for message delivery, and AWS EventBridge Scheduler to trigger messages at specific times. The API interacts with a database (like PostgreSQL) to store schedule information and with AWS services to manage the scheduling and publishing of messages.

What is AWS EventBridge Scheduler used for?

EventBridge Scheduler is a serverless scheduling service that allows you to precisely schedule one-time or recurring tasks. In this system, it's used to trigger messages to be sent to an AWS SNS topic at the user-defined times, replacing the need for complex cron jobs or maintaining scheduler instances.

Why use Node.js and Express for a reminder system?

Node.js and Express are chosen for their popularity, ease of use in building RESTful APIs, and ability to handle asynchronous operations efficiently. Express simplifies routing and request handling, making it ideal for the API layer of this system.

When should I use cron expressions vs. scheduledTime?

Use cron expressions for recurring reminders, such as daily or weekly notifications, by specifying the cron syntax in the 'cronExpression' field. Use 'scheduledTime' for one-time reminders, providing an ISO 8601 timestamp for the specific future time the message should be sent.

What AWS services are required for the reminder system?

The system uses AWS Simple Notification Service (SNS) to manage message publishing and subscriptions, and AWS EventBridge Scheduler to trigger notifications at scheduled times. You'll also need an IAM user for programmatic access, an IAM role for the Scheduler and a Database (Postgres, MySQL, SQLite etc.) to store message details.

How to set up the database for this reminder app?

The example uses PostgreSQL and Prisma ORM, where Prisma defines the database schema (`schema.prisma`) and manages database interactions. You'll need to define a 'Schedule' model in your `schema.prisma` file and migrate it to your database instance using Prisma CLI commands.

How to create a recurring schedule every weekday?

When creating a schedule via the API, provide a cron expression in the `cronExpression` field. For a weekday schedule, a cron expression like `cron(0 10 ? * MON-FRI *)` would trigger every weekday at 10:00 AM UTC. Refer to the AWS documentation for detailed cron syntax.

Can I use a database other than PostgreSQL?

Yes, while the guide uses PostgreSQL with Prisma, you can adapt the concepts and use Prisma with MySQL, SQLite, SQL Server, or MongoDB by changing the `provider` and `DATABASE_URL` accordingly in `schema.prisma`.

How to send the reminder message to multiple destinations?

This system leverages AWS SNS topics, which support multiple subscribers. After setting up the reminder system, subscribe different endpoints (email, SMS, Lambda functions, etc.) to the SNS topic to receive the messages when they're published.

How to delete an existing scheduled reminder?

Send a DELETE request to the API endpoint `/api/schedules/{id}`, replacing `{id}` with the schedule's ID. This triggers logic to remove both the scheduled task from AWS EventBridge and the schedule details from the database.

What are the prerequisites for building this application?

You need Node.js and npm/yarn, an AWS account, AWS CLI (optional), basic understanding of JavaScript, Node.js, Express, and REST APIs, a text editor, Docker (for containerization), and a PostgreSQL database (or another supported database).

How does containerization with Docker help deployment?

Docker allows you to package the application and its dependencies into a container, which ensures consistent execution across different environments. This simplifies deployment to various platforms (like AWS ECS, EKS, or App Runner) as the container includes everything needed to run the app.

What are some ways to improve the error handling of the system?

You can implement more specific error catching within the controller and service layers to provide more informative error messages to users. A global error handler in the Express app can ensure consistent responses for errors, and using a structured logging library like Winston provides more comprehensive error logging for debugging.

How does the reminder system handle retries if sending a notification fails?

While the base implementation doesn't explicitly retry, you can configure EventBridge Scheduler's retry policy in the `RetryPolicy` section of `awsService.js`. Set `MaximumEventAgeInSeconds` and `MaximumRetryAttempts` to control how long EventBridge retries before giving up. Additionally, you can configure a Dead Letter Queue to capture and analyze failed invocations.