AWS Lambda CloudWatch Jobs TypeScript
Comprehensive guide to setting up scheduled jobs with AWS Lambda and CloudWatch/EventBridge using TypeScript - from basics to best practices
Need to automate periodic tasks like backups, email sending, or data processing? AWS Lambda combined with CloudWatch Events/EventBridge is the perfect serverless solution to replace traditional cron jobs. This guide focuses on TypeScript/Node.js implementations for production-ready scheduled jobs.
Overview of Lambda Scheduled Jobs#
AWS Lambda is a serverless compute service that allows you to run code without managing servers. When combined with scheduling services, you can:
- Automate tasks: Backups, report generation, data cleanup
- Save costs: Pay only when code executes
- Auto-scale: Handle increased load without configuration
- High availability: AWS ensures availability
[!NOTE] AWS currently recommends using EventBridge Scheduler instead of legacy CloudWatch Events for new scheduled jobs.
flowchart LR
subgraph Scheduler["Trigger Services"]
direction TB
EBS["EventBridge Scheduler<br/>(Timezone / Retry / Flexible window)"]
CWE["CloudWatch Events<br/>(Legacy Cron)"]
end
subgraph Serverless["Serverless Compute"]
Lambda["AWS Lambda Function<br/>(Node.js / TypeScript)"]
DLQ["Dead Letter Queue (SQS)<br/>(Failed invocations storage)"]
end
subgraph Targets["Target Tasks & Monitoring"]
DB[(Database / S3 Bucket)]
Log["CloudWatch Logs / Alarms"]
end
EBS -->|Scheduled Invoke| Lambda
CWE -.->|Basic Invoke| Lambda
Lambda -.->|Failures / Retry Exceeded| DLQ
Lambda -->|Read / Write Data| DB
Lambda -->|Emit Logs & Metrics| Log
EventBridge Scheduler is the modern scheduling service with enhanced features compared to CloudWatch Events.
Advantages of EventBridge Scheduler#
- Timezone support: Not limited to UTC only
- Flexible time windows: Run within flexible time ranges
- Built-in retry: Automatic retry on failure
- One-time schedules: Support for single execution
- Dead Letter Queue: Better failure handling
Step 1: Create TypeScript Lambda Function#
import { Context } from 'aws-lambda';
import { DynamoDB } from 'aws-sdk';
import { logger } from './utils/logger';
interface ScheduledEvent {
time: string;
detail?: any;
}
interface LambdaResponse {
statusCode: number;
body: string;
}
export const lambdaHandler = async (
event: ScheduledEvent,
context: Context
): Promise<LambdaResponse> => {
logger.info(`Job triggered at: ${new Date().toISOString()}`);
logger.info(`Event: ${JSON.stringify(event)}`);
try {
// Your job processing logic
const result = await processScheduledTask(event);
return {
statusCode: 200,
body: JSON.stringify({
message: 'Job completed successfully',
result
})
};
} catch (error) {
logger.error(`Job failed: ${error}`);
throw error;
}
};
async function processScheduledTask(event: ScheduledEvent): Promise<any> {
// Example: cleanup database, send report, etc.
logger.info('Processing scheduled task...');
// Your business logic here
return { status: 'success', processedItems: 10 };
}typescriptStep 2: Create IAM Role#
Lambda needs permissions to execute the job and write logs:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}jsonPolicy permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
},
{
"Effect": "Allow",
"Action": [
"dynamodb:*",
"s3:*"
],
"Resource": "*"
}
]
}jsonStep 3: Create Schedule with EventBridge Scheduler#
Using AWS Console#
- Go to EventBridge Scheduler console
- Click Create schedule
- Select Recurring schedule
- Configure schedule expression:
# Run daily at 9:00 AM UTC
cron(0 9 * * ? *)
# Run every 15 minutes
rate(15 minutes)
# Run weekly on Monday 9:00 AM
cron(0 9 ? * MON *)
# Run on first day of each month
cron(0 0 1 * ? *)bash- Select Lambda function as target
- Configure timezone (if needed):
# 9:00 AM Eastern Time
cron(0 9 ? * MON *)
timezone: America/New_Yorkbash- Configure flexible time window (optional):
# Run within 15 minutes after scheduled time
mode: FLEXIBLE
maximum_window_in_minutes: 15bash- Setup retry policy:
maximum_retry_attempts: 3
maximum_event_age_in_seconds: 3600bashUsing AWS CLI#
aws scheduler create-schedule \
--name daily-job \
--schedule-expression 'cron(0 9 * * ? *)' \
--schedule-expression-timezone 'UTC' \
--target '{
"Arn": "arn:aws:lambda:us-east-1:123456789012:function:my-function",
"RoleArn": "arn:aws:iam::123456789012:role/scheduler-role"
}' \
--flexible-time-window '{
"Mode": "FLEXIBLE",
"MaximumWindowInMinutes": 15
}'bashUsing Terraform#
resource "aws_scheduler_schedule" "daily_job" {
name = "daily-job"
group_name = "default"
flexible_time_window {
mode = "FLEXIBLE"
maximum_window_in_minutes = 15
}
schedule_expression = "cron(0 9 * * ? *)"
schedule_expression_timezone = "UTC"
target {
arn = aws_lambda_function.my_function.arn
role_arn = aws_iam_role.scheduler_role.arn
retry_policy {
maximum_retry_attempts = 3
maximum_event_age_in_seconds = 3600
}
dead_letter_config {
arn = aws_sqs_queue.dlq.arn
}
}
}hclMethod 2: CloudWatch Events (Legacy)#
CloudWatch Events is the traditional method, still supported but with fewer features.
Step 1: Create Lambda Function#
Same as Scheduler method.
Step 2: Create CloudWatch Rule#
Using AWS Console#
- Go to CloudWatch console
- Select Events → Rules
- Click Create rule
- Select Schedule expression
- Enter cron expression:
# 9:00 AM UTC daily
0 9 * * ? *
# Every 5 minutes
rate(5 minutes)bash- Select Lambda function as target
- Configure permission (AWS will auto-create)
Using AWS CLI#
aws events put-rule \
--name daily-lambda-rule \
--schedule-expression 'cron(0 9 * * ? *)'
aws events put-targets \
--rule daily-lambda-rule \
--targets '{
"Id": "1",
"Arn": "arn:aws:lambda:us-east-1:123456789012:function:my-function"
}'
aws lambda add-permission \
--function-name my-function \
--statement-id daily-lambda-rule \
--action 'lambda:InvokeFunction' \
--principal events.amazonaws.com \
--source-arn arn:aws:events:us-east-1:123456789012:rule/daily-lambda-rulebashCron Expression Guide#
EventBridge uses 6-field cron expression:
cron(Minutes Hours Day-of-month Month Day-of-week Year)textExample Cron Expressions#
# Daily at 9:00 AM UTC
cron(0 9 * * ? *)
# Weekly on Monday 9:00 AM
cron(0 9 ? * MON *)
# Monthly on day 1 at 00:00
cron(0 0 1 * ? *)
# Every 15 minutes
rate(15 minutes)
# Every 1 hour
rate(1 hour)
# 9:00 AM Monday to Friday
cron(0 9 ? * MON-FRI *)
# Last day of month
cron(0 0 L * ? *)
# First Monday of each month
cron(0 0 ? * MON#1 *)bashField Values#
| Field | Values | Special Characters |
|---|---|---|
| Minutes | 0-59 | , - * / |
| Hours | 0-23 | , - * / |
| Day of month | 1-31, L, W | , - * / ? L W |
| Month | 1-12, JAN-DEC | , - * / |
| Day of week | 1-7, SUN-SAT, ?, L, # | , - * / ? L # |
| Year | 1970-2199 | , - * / |
[!WARNING] Use
?when day-of-month or day-of-week is constrained, don’t use both fields simultaneously.
Best Practices#
1. Idempotency#
Ensure your handler is idempotent for safe retries:
import { DynamoDB } from 'aws-sdk';
const dynamodb = new DynamoDB.DocumentClient();
const TABLE_NAME = 'job-locks';
export const lambdaHandler = async (event: ScheduledEvent): Promise<any> => {
const jobId = `${event.time}-${event.detail?.id || 'default'}`;
// Check if job already ran
if (await isJobAlreadyProcessed(jobId)) {
logger.info('Job already processed, skipping');
return { status: 'skipped' };
}
// Process job
const result = await processJob(event);
// Mark job as processed
await markJobAsProcessed(jobId);
return result;
};
async function isJobAlreadyProcessed(jobId: string): Promise<boolean> {
try {
const result = await dynamodb.get({
TableName: TABLE_NAME,
Key: { jobId }
}).promise();
return !!result.Item;
} catch (error) {
logger.error(`Error checking job status: ${error}`);
return false;
}
}
async function markJobAsProcessed(jobId: string): Promise<void> {
await dynamodb.put({
TableName: TABLE_NAME,
Item: {
jobId,
processedAt: new Date().toISOString(),
ttl: Math.floor(Date.now() / 1000) + (7 * 24 * 60 * 60) // 7 days TTL
}
}).promise();
}typescript2. Dead Letter Queue (DLQ)#
Setup DLQ to handle failed invocations:
import { SQS } from 'aws-sdk';
const sqs = new SQS();
const DLQ_URL = process.env.DLQ_URL || '';
async function sendToDLQ(errorMessage: string, event: ScheduledEvent): Promise<void> {
await sqs.sendMessage({
QueueUrl: DLQ_URL,
MessageBody: JSON.stringify({
error: errorMessage,
event,
timestamp: new Date().toISOString()
})
}).promise();
}
export const lambdaHandler = async (event: ScheduledEvent): Promise<any> => {
try {
const result = await processJob(event);
return result;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Job failed: ${errorMessage}`);
// Send to DLQ
await sendToDLQ(errorMessage, event);
throw error;
}
};typescript3. Overlap Prevention#
Prevent overlapping executions with DynamoDB lock:
import { DynamoDB } from 'aws-sdk';
const dynamodb = new DynamoDB.DocumentClient();
const LOCK_TABLE = 'job-locks';
interface LockItem {
jobId: string;
lockedAt: string;
expiresAt: string;
}
async function acquireLock(jobId: string): Promise<boolean> {
const now = new Date();
const expiresAt = new Date(now.getTime() + 10 * 60 * 1000); // 10 minutes
try {
await dynamodb.put({
TableName: LOCK_TABLE,
Item: {
jobId,
lockedAt: now.toISOString(),
expiresAt: expiresAt.toISOString()
},
ConditionExpression: 'attribute_not_exists(jobId)'
}).promise();
return true;
} catch (error) {
if ((error as any).code === 'ConditionalCheckFailedException') {
return false; // Lock already exists
}
throw error;
}
}
async function releaseLock(jobId: string): Promise<void> {
await dynamodb.delete({
TableName: LOCK_TABLE,
Key: { jobId }
}).promise();
}
export const lambdaHandler = async (event: ScheduledEvent): Promise<any> => {
const jobId = `scheduled-job-${event.time}`;
// Acquire lock
const lockAcquired = await acquireLock(jobId);
if (!lockAcquired) {
logger.info('Job already running, skipping');
return { status: 'skipped', reason: 'lock_not_acquired' };
}
try {
const result = await processJob(event);
return result;
} finally {
// Release lock
await releaseLock(jobId);
}
};typescript4. Monitoring and Logging#
Use CloudWatch Logs and Metrics:
import { CloudWatch } from 'aws-sdk';
const cloudwatch = new CloudWatch({ region: process.env.AWS_REGION });
export const lambdaHandler = async (event: ScheduledEvent, context: Context): Promise<any> => {
const startTime = Date.now();
try {
// Custom metrics
logger.info('JOB_STARTED');
const result = await processJob(event);
// Log duration
const duration = Date.now() - startTime;
logger.info(`JOB_COMPLETED duration=${duration}ms`);
// Send custom metric
await cloudwatch.putMetricData({
Namespace: 'ScheduledJobs',
MetricData: [
{
MetricName: 'JobDuration',
Value: duration,
Unit: 'Milliseconds',
Dimensions: [
{
Name: 'JobName',
Value: 'DailyCleanup'
}
]
}
]
}).promise();
return result;
} catch (error) {
const duration = Date.now() - startTime;
logger.error(`JOB_FAILED error=${error} duration=${duration}ms`);
// Send failure metric
await cloudwatch.putMetricData({
Namespace: 'ScheduledJobs',
MetricData: [
{
MetricName: 'JobFailures',
Value: 1,
Unit: 'Count',
Dimensions: [
{
Name: 'JobName',
Value: 'DailyCleanup'
}
]
}
]
}).promise();
throw error;
}
};typescript5. Error Handling#
Handle errors gracefully:
interface APIResponse {
statusCode: number;
body: string;
}
export const lambdaHandler = async (event: ScheduledEvent): Promise<APIResponse> => {
try {
const result = await processJob(event);
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
if (error instanceof ValidationError) {
logger.error(`Validation error: ${error.message}`);
return {
statusCode: 400,
body: JSON.stringify({ error: error.message })
};
} else if (error instanceof BusinessError) {
logger.error(`Business error: ${error.message}`);
return {
statusCode: 422,
body: JSON.stringify({ error: error.message })
};
} else {
logger.error(`Unexpected error: ${error}`);
// Send to DLQ or alert
await sendAlert(error instanceof Error ? error.message : String(error));
throw error;
}
}
};
class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'ValidationError';
}
}
class BusinessError extends Error {
constructor(message: string) {
super(message);
this.name = 'BusinessError';
}
}typescriptComparison: EventBridge Scheduler vs CloudWatch Events#
| Feature | EventBridge Scheduler | CloudWatch Events |
|---|---|---|
| Schedule types | cron + rate + one-time | cron + rate |
| Timezone support | ✅ Any timezone | ❌ UTC only |
| Flexible windows | ✅ Yes | ❌ No |
| Built-in retry | ✅ Yes | ❌ No (need DLQ) |
| Cost | $1.00/M invocations | Free (first 5M/month) |
| Use case | Production jobs | Simple recurring jobs |
Practical Example: Daily Database Cleanup#
import { DynamoDB } from 'aws-sdk';
const dynamodb = new DynamoDB.DocumentClient();
const TABLE_NAME = 'user-activity';
interface CleanupResult {
deletedCount: number;
cutoffDate: string;
}
export const lambdaHandler = async (): Promise<APIResponse> => {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - 30);
try {
// Scan and delete old records
const response = await dynamodb.scan({
TableName: TABLE_NAME,
FilterExpression: 'created_at < :cutoff',
ExpressionAttributeValues: {
':cutoff': cutoffDate.toISOString()
}
}).promise();
let deletedCount = 0;
if (response.Items) {
for (const item of response.Items) {
await dynamodb.delete({
TableName: TABLE_NAME,
Key: { id: item.id }
}).promise();
deletedCount++;
}
}
logger.info(`Deleted ${deletedCount} old records`);
return {
statusCode: 200,
body: JSON.stringify({
deletedCount,
cutoffDate: cutoffDate.toISOString()
} as CleanupResult)
};
} catch (error) {
logger.error(`Cleanup failed: ${error}`);
throw error;
}
};typescriptSchedule with EventBridge Scheduler:
# Run daily at 2:00 AM UTC
cron(0 2 * * ? *)bashTroubleshooting#
Job not running#
- Check schedule expression: Ensure cron syntax is correct
- Verify IAM permissions: Lambda has necessary permissions
- Check CloudWatch Logs: Look for error messages
- Verify target ARN: Ensure Lambda function ARN is correct
Job runs but fails#
- Review CloudWatch Logs: Find error messages
- Check timeout: Lambda might be timing out
- Verify resource permissions: Database, S3, etc. access
- Test locally: Run Lambda locally to debug
Overlapping executions#
- Implement lock mechanism: Use DynamoDB or Redis
- Use reserved concurrency: Limit concurrent executions
- Add idempotency: Ensure handler is idempotent
Cleanup Resources#
When no longer needed, clean up resources:
# Delete schedule
aws scheduler delete-schedule --name daily-job
# Delete CloudWatch rule
aws events delete-rule --name daily-lambda-rule
# Delete Lambda function
aws lambda delete-function --function-name my-function
# Delete IAM role
aws iam delete-role --role-name scheduler-rolebashConclusion#
AWS Lambda combined with EventBridge Scheduler/CloudWatch Events provides a powerful solution for scheduled jobs:
- EventBridge Scheduler: For production jobs with timezone support, retry, flexible windows
- CloudWatch Events: For simple recurring jobs with cost efficiency
Key takeaways:
- Use EventBridge Scheduler for production
- Implement idempotency and error handling
- Setup monitoring with CloudWatch
- Use DLQ for failed invocations
- Prevent overlapping executions