code examples
code examples
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:
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- A client sends a request to the Express API to create a schedule (message, target time/cron, SNS topic).
- The API saves the schedule details to a database (using Prisma).
- The API creates a corresponding schedule in AWS EventBridge Scheduler, configuring it to target the specified SNS topic with the message payload.
- At the scheduled time, EventBridge Scheduler triggers and sends the payload to the SNS topic.
- 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.
-
Create Project Directory: Open your terminal and create a new directory for the project.
bashmkdir node-sns-scheduler cd node-sns-scheduler -
Initialize Node.js Project:
bashnpm init -yThis creates a
package.jsonfile. -
Install Dependencies: We need Express for the API, the AWS SDK v3 for interacting with AWS services, Prisma for database interaction,
dotenvfor environment variables, anduuidfor generating unique IDs.bashnpm install express @aws-sdk/client-sns @aws-sdk/client-scheduler @prisma/client dotenv uuid npm install --save-dev prisma nodemonexpress: 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.envfile.uuid: Generates unique identifiers for schedule names.prisma(dev): ORM toolkit for database management.nodemon(dev): Utility to automatically restart the server during development.
-
Configure
package.jsonScripts: 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"" } } -
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 -
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* -
Initialize Prisma: Set up Prisma with PostgreSQL (you can change
postgresqltomysql,sqlite,sqlserver, ormongodbif needed, adapting theDATABASE_URLformat accordingly).bashnpx prisma init --datasource-provider postgresqlThis creates the
prisma/directory and aschema.prismafile, and updates.envwith a placeholderDATABASE_URL. -
Configure Environment Variables (
.env): Create a.envfile in the project root. Crucially, you MUST replace theYOUR_...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.
dotenvloads these intoprocess.env. - How to obtain values: Details are in the subsequent AWS Setup sections. Do not proceed without replacing the placeholders.
- Purpose: Storing sensitive credentials and configuration outside the codebase.
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.
-
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
FullAccesspolicies. Create custom IAM policies granting only the minimum required permissions:- For SNS:
sns:Publish(potentially restricted to your specificSNS_TOPIC_ARN),sns:ListTopics. - For EventBridge Scheduler:
scheduler:CreateSchedule,scheduler:DeleteSchedule,scheduler:GetSchedule,scheduler:ListSchedules. - You will also need
iam:PassRolepermission allowing the user to pass the EventBridge execution role (EVENTBRIDGE_SCHEDULER_ROLE_ARN) to the Scheduler service.
- For SNS:
- 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
.envfile with these credentials (AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY).
-
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
.envfile with thisSNS_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.
-
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
AmazonSNSFullAccesshere in production. Create a more restrictive custom inline policy allowing only thesns:Publishaction 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 } ] }
- Crucial for Production: For enhanced security, do not use
- 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
.envfile with thisEVENTBRIDGE_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.
-
Define the Schema (
prisma/schema.prisma): Openprisma/schema.prismaand 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 ofcronExpressionorscheduledTimemust 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.
-
Run Database Migration: Apply the schema changes to your database. Prisma will create the
Scheduletable.bashnpx prisma migrate dev --name init-schedule-modelFollow the prompts (it will ask for a migration name if you omit
--name). Ensure your database server is running and accessible using theDATABASE_URLin.env. -
Generate Prisma Client: Update the Prisma client based on your schema.
bashnpx prisma generate -
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.
-
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
SchedulerClientandSNSClient: These are the dedicated SDK clients for interacting with their respective services. - Why
CreateScheduleCommand: This SDK command maps directly to the EventBridgeCreateScheduleAPI 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 usescron(...)orat(...)syntax. We convert the inputcronExpressionorscheduledTimeaccordingly.at()expectsyyyy-mm-ddThh:mm:ssformat (typically UTC). - Why
Targetconfiguration: 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 (atexpressions), 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
ResourceNotFoundExceptionduring delete gracefully.
- Why
-
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.envfile. - Why Validation: Ensures critical configuration is present and placeholders have likely been replaced at startup.
- Why
5. Building the API Layer (Express Routes & Controllers)
Define the API endpoints and the logic to handle requests.
-
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 forscheduledTime). - Core Logic (
createSchedule):- Generates a unique
scheduleId. - Calls
awsService.createEventBridgeSchedule, passing necessary parameters and ensuring the message is JSON stringified as required by SNS targets in EventBridge. - If successful, saves the schedule details (including the
eventBridgeRuleNamereturned by the AWS service) to the database using Prisma. - Includes basic cleanup logic: If the AWS call succeeds but the database save fails, it attempts to delete the orphaned EventBridge schedule.
- Generates a unique
- Core Logic (
deleteSchedule):- Finds the schedule in the database using the provided
id. - Retrieves the
eventBridgeRuleNamefrom the database record. - Calls
awsService.deleteEventBridgeSchedulewith the retrieved rule name. - Deletes the record from the database.
- Finds the schedule in the database using the provided
- Error Handling: Uses
try...catchblocks and passes errors to Express'snextfunction for centralized error handling (which should be implemented inserver.js).
- Input Validation: Checks for required fields (
-
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.
-
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
scheduleRoutesunder the/api/schedulespath. - 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
SIGINTandSIGTERMto close the server and database connection properly before exiting. Also includes basic handlers forunhandledRejectionanduncaughtException.
- Middleware: Uses
6. Running and Testing the Application
-
Ensure Database is Running: Make sure your PostgreSQL (or chosen database) server is running and accessible via the
DATABASE_URLin your.envfile. -
Run Migrations: If you haven't already, apply the database schema:
bashnpx prisma migrate dev -
Start the Development Server:
bashnpm run devNodemon will watch for file changes and restart the server automatically.
-
Test with
curlor an API Client (e.g., Postman, Insomnia):-
Create a One-Time Schedule: Replace
YYYY-MM-DDTHH:MM:SSZwith a future UTC time.bashcurl -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):
bashcurl -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:
bashcurl http://localhost:3000/api/schedulesExpected Response (200 OK): JSON array of all schedule objects stored in the database. Note the
idof a schedule you want to delete. -
Delete a Schedule: Replace
{SCHEDULE_ID}with the actualidobtained from the list or create response.bashcurl -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.
-
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 /usr/src/app/node_modules/.prisma ./node_modules/.prisma COPY /usr/src/app/node_modules/@prisma/client ./node_modules/@prisma/client # Copy application code COPY /usr/src/app/src ./src COPY /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"" ] -
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 -
Build the Docker Image:
bashdocker build -t node-sns-scheduler-app . -
Run the Docker Container: You need to pass the environment variables from your
.envfile to the container.bashdocker 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.envfile 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:3000just 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
joiorzodfor 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
scheduledTimeandcronExpressionif your users are in different timezones. EventBridge Scheduler supportsScheduleExpressionTimezone. - 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.