How to schedule cron jobs on AWS: EventBridge vs Lambda vs ECS

6 min readcronawsdevops

If you've ever needed to run a task on a schedule in AWS, you've probably discovered that there are at least three different ways to do it — and choosing the right one matters more than you'd think.

The three options

AWS gives you three main approaches to scheduled execution, each with different trade-offs in cost, complexity, and reliability.

EventBridge Scheduler (formerly CloudWatch Events) is the most straightforward. You define a schedule using a cron expression or rate expression, point it at a target (Lambda function, ECS task, Step Functions workflow, SNS topic, or SQS queue), and AWS handles the rest. This is the right choice for 90% of scheduled jobs.

Lambda with EventBridge trigger is the classic pattern. EventBridge fires on schedule and invokes a Lambda function. The function runs, does its work, and exits. You pay only for execution time. The constraint is Lambda's 15-minute maximum execution time — if your job takes longer, you need a different approach.

ECS Scheduled Tasks run a container on a schedule using Fargate or EC2. There's no execution time limit, you get full control over the runtime environment, and you can run jobs that need more memory or CPU than Lambda provides. The trade-off is higher cost and more configuration.

Cron expressions in AWS

AWS EventBridge uses a six-field cron format, not the standard five-field Unix format. The extra field is for the year, and there are some syntax differences you need to know about.

Standard Unix cron:

# minute hour day-of-month month day-of-week
0 9 * * 1-5

AWS EventBridge cron:

# minute hour day-of-month month day-of-week year
0 9 ? * MON-FRI *

The ? character means "no specific value" and is required in either the day-of-month or day-of-week field (but not both). AWS also uses three-letter day and month names instead of numbers.

Here are some common schedules translated between the two formats:

| Schedule | Unix cron | AWS EventBridge | | --- | --- | --- | | Every 5 minutes | */5 * * * * | 0/5 * * * ? * | | Weekdays at 9am | 0 9 * * 1-5 | 0 9 ? * MON-FRI * | | First of every month | 0 0 1 * * | 0 0 1 * ? * | | Every Sunday at midnight | 0 0 * * 0 | 0 0 ? * SUN * |

Note that AWS uses 0/5 instead of */5 for step values in some contexts, and day-of-week numbering uses names rather than numbers.

Setting up EventBridge + Lambda

The most common pattern is an EventBridge rule that triggers a Lambda function. Here's what that looks like in the AWS CDK:

# AWS CDK (TypeScript)
const fn = new lambda.Function(this, 'MyScheduledJob', {
  runtime: lambda.Runtime.NODEJS_20_X,
  handler: 'index.handler',
  code: lambda.Code.fromAsset('lambda'),
  timeout: Duration.minutes(5),
});

new events.Rule(this, 'ScheduleRule', {
  schedule: events.Schedule.expression('cron(0 9 ? * MON-FRI *)'),
  targets: [new targets.LambdaFunction(fn)],
});

And in Terraform:

resource "aws_lambda_function" "scheduled_job" {
  function_name = "my-scheduled-job"
  handler       = "index.handler"
  runtime       = "nodejs20.x"
  timeout       = 300
  # ... other config
}

resource "aws_cloudwatch_event_rule" "schedule" {
  name                = "weekday-9am"
  schedule_expression = "cron(0 9 ? * MON-FRI *)"
}

resource "aws_cloudwatch_event_target" "lambda" {
  rule = aws_cloudwatch_event_rule.schedule.name
  arn  = aws_lambda_function.scheduled_job.arn
}

Both approaches create the same thing: a rule that fires at 9am on weekdays and invokes your Lambda function.

When Lambda isn't enough

Lambda's 15-minute timeout is a hard limit. If your job regularly takes 10+ minutes, you're living dangerously — network slowdowns, larger-than-usual datasets, or cold starts could push you over the edge with no warning.

Signs you've outgrown Lambda for scheduled work:

  • Jobs that process large datasets (database migrations, batch ETL)
  • Tasks that make many sequential API calls with rate limiting
  • Anything involving file processing on large files (video transcoding, PDF generation)
  • Jobs that need persistent connections (long-running database transactions)

For these cases, ECS Scheduled Tasks with Fargate are the natural next step. You get the same scheduling via EventBridge but the execution environment is a container with no timeout limit.

Comparison at a glance

| Option | Max runtime | Cost model | Best for | | --- | --- | --- | --- | | EventBridge + Lambda | 15 minutes | Per invocation + duration | Most scheduled jobs | | ECS Scheduled Tasks | Unlimited | Per vCPU/memory-hour | Long-running or memory-heavy jobs | | Step Functions | 1 year | Per state transition | Multi-step workflows |

Rate expressions vs cron expressions

EventBridge supports both cron expressions and rate expressions. Rate expressions are simpler:

rate(5 minutes)
rate(1 hour)
rate(7 days)

Use rate expressions when you want a simple fixed interval. Use cron expressions when you need specificity — "weekdays at 9am" or "first Monday of each month" can't be expressed as a rate.

One gotcha: rate(1 day) runs every 24 hours from when the rule was created, not at midnight. If you created the rule at 3:47pm, it runs at 3:47pm every day. For clock-aligned schedules, always use cron.

Timezone handling

EventBridge Scheduler supports timezone-aware schedules, which is a significant improvement over the original CloudWatch Events approach where everything ran in UTC.

Always specify a timezone explicitly in your EventBridge schedule. Relying on UTC and converting mentally is a reliable source of off-by-one-hour bugs, especially around daylight saving time transitions.

If you're using the CDK or Terraform, the timezone field is straightforward:

# CDK
new scheduler.Schedule(this, 'MySchedule', {
  schedule: scheduler.ScheduleExpression.cron({
    minute: '0',
    hour: '9',
    weekDay: 'MON-FRI',
  }),
  scheduleTimezone: 'Europe/London',
  target: new targets.LambdaInvoke(fn),
});

Be especially careful around daylight saving transitions. A job scheduled for 1:30am in Europe/London will either run twice or not at all on the transition days, depending on the implementation. If your job can't tolerate this, schedule it for a time that exists in both GMT and BST — anything between 2am and midnight is safe.

Error handling and monitoring

Scheduled jobs fail silently by default. EventBridge will invoke your target and move on — if the Lambda throws an error, EventBridge doesn't retry (unless you configure a dead-letter queue).

At minimum, set up:

  • A dead-letter queue on the EventBridge rule to capture failed invocations
  • CloudWatch alarms on Lambda errors and duration (alert if a job approaches the timeout)
  • Structured logging inside your Lambda so you can trace execution in CloudWatch Logs
  • An "I'm still alive" metric — have the job emit a custom CloudWatch metric on success, then alarm if the metric stops appearing

The last point is often overlooked. It's easy to monitor failures but harder to detect non-execution — the rule was accidentally deleted, the IAM policy changed, the schedule was wrong. A missing heartbeat catches all of these.