Almost every serverless application eventually grows a clock: nightly reports, hourly reconciliation, a per-customer reminder, a monthly invoice run. On AWS you now have two genuinely different schedulers plus several ways to survive jobs that outlive Lambda's 15-minute timeout. This post covers when to use each one, the Serverless Framework V4 configuration for all of them, and the operational details (time zones, retries, idempotency, monitoring) that decide whether your cron jobs are boring or a permanent source of 3 a.m. pages.
Two schedulers, one decision
EventBridge rules (schedule event) | EventBridge Scheduler | |
|---|---|---|
| Defined in | serverless.yml under a function's events | API/SDK at runtime, or IaC for fixed schedules |
| Good for | A fixed set of app-wide jobs | Thousands of dynamic, per-entity, often one-off schedules |
| Time zones | UTC only | Named time zones with DST handling |
| One-time schedules | No | Yes (at(...) expressions) |
| Flexible time windows | No | Yes, spread invocations over a window |
| Targets | Lambda, Step Functions, SQS, and more | Over 270 API actions, including Lambda and Step Functions |
| Delete after run | Manual | ActionAfterCompletion: DELETE |
The rule of thumb we use on engagements: if the schedule is part of the application's design, put it in serverless.yml as a rule. If the schedule is part of the application's data — "remind this user at 09:00 in their own time zone" — create it at runtime with EventBridge Scheduler.
Fixed jobs: schedule events in serverless.yml
The classic form. Both rate() and cron() expressions work, and cron() on EventBridge rules takes six fields (minute, hour, day-of-month, month, day-of-week, year) in UTC:
service: billing-jobs
provider:
name: aws
runtime: nodejs22.x
region: eu-west-1
memorySize: 512
architecture: arm64
functions:
nightlyReconcile:
handler: src/jobs/reconcile.handler
timeout: 900 # the Lambda maximum: 15 minutes
events:
- schedule:
rate: cron(15 2 * * ? *) # 02:15 UTC daily
name: nightly-reconcile-${sls:stage}
description: Reconcile payment ledger against provider settlements
enabled: true
input:
job: reconcile
lookbackHours: 26
heartbeat:
handler: src/jobs/heartbeat.handler
events:
- schedule: rate(5 minutes)
Three details worth knowing:
- Name your rules per stage. Without
name, two stages in the same account can be hard to tell apart in the console; with a stage suffix,devandprodrules are obvious and never collide. inputbeats environment variables when one handler serves several schedules. One function, three rules, three payloads, no branching on time-of-day.- Disable in non-production. A nightly job that runs in every ephemeral stage is a cost and correctness hazard. Gate it:
events:
- schedule:
rate: cron(15 2 * * ? *)
enabled: ${param:jobsEnabled, false}
with jobsEnabled: true set only in the prod stage params. Ephemeral review stages then deploy the code without arming the clock.
Dynamic and per-entity jobs: EventBridge Scheduler
EventBridge Scheduler is a separate service from EventBridge rules, built for schedule-per-record workloads. It supports one-time at() schedules, named time zones, and automatic deletion after a completed run — which is what makes "a million pending reminders" practical instead of an account-limit problem.
Declare the schedule group and the role the scheduler assumes in resources, then create schedules from your own code.
resources:
Resources:
ReminderGroup:
Type: AWS::Scheduler::ScheduleGroup
Properties:
Name: reminders-${sls:stage}
SchedulerInvokeRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal: { Service: scheduler.amazonaws.com }
Action: sts:AssumeRole
Condition:
StringEquals:
aws:SourceAccount: ${aws:accountId}
Policies:
- PolicyName: invoke-reminder
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource:
- !GetAtt SendReminderLambdaFunction.Arn
SendReminderLambdaFunction is the logical ID the Serverless Framework generates for a function named sendReminder; the pattern is the camel-cased name plus LambdaFunction.
The function that creates schedules needs scheduler:CreateSchedule, scheduler:DeleteSchedule and iam:PassRole limited to that one role:
provider:
iam:
role:
statements:
- Effect: Allow
Action:
- scheduler:CreateSchedule
- scheduler:UpdateSchedule
- scheduler:DeleteSchedule
- scheduler:GetSchedule
Resource: arn:aws:scheduler:${aws:region}:${aws:accountId}:schedule/reminders-${sls:stage}/*
- Effect: Allow
Action: iam:PassRole
Resource: !GetAtt SchedulerInvokeRole.Arn
And the handler:
import { SchedulerClient, CreateScheduleCommand } from '@aws-sdk/client-scheduler';
const scheduler = new SchedulerClient({});
export const handler = async (event) => {
const { reminderId, runAtIso, timezone, userId } = JSON.parse(event.body);
await scheduler.send(new CreateScheduleCommand({
Name: `reminder-${reminderId}`,
GroupName: process.env.SCHEDULE_GROUP,
ScheduleExpression: `at(${runAtIso})`, // e.g. at(2026-03-29T09:00:00)
ScheduleExpressionTimezone: timezone, // e.g. Europe/London
FlexibleTimeWindow: { Mode: 'OFF' },
ActionAfterCompletion: 'DELETE', // no cleanup job needed
Target: {
Arn: process.env.REMINDER_FUNCTION_ARN,
RoleArn: process.env.SCHEDULER_ROLE_ARN,
Input: JSON.stringify({ reminderId, userId }),
RetryPolicy: { MaximumRetryAttempts: 3, MaximumEventAgeInSeconds: 3600 },
DeadLetterConfig: { Arn: process.env.JOB_DLQ_ARN },
},
}));
return { statusCode: 202, body: JSON.stringify({ reminderId }) };
};
Note ScheduleExpressionTimezone. Scheduler resolves at(2026-03-29T09:00:00) in Europe/London correctly across the DST boundary; an EventBridge rule in UTC would fire an hour off for half the year. If a user changes their reminder time, call UpdateSchedule; if they cancel, DeleteSchedule — the schedule is the state, which is far less code than a table of pending jobs plus a sweeper.
Use FlexibleTimeWindow with Mode: 'FLEXIBLE' and a window of, say, 15 minutes when thousands of schedules would otherwise fire on the same minute and hammer a downstream API.
Jobs that will not fit in 15 minutes
Lambda's hard ceiling is 900 seconds. When a nightly job outgrows it, the answer is not a bigger timeout. Pick one of these three.
1. Fan out. Split the work into chunks and let concurrency do the work. A "dispatcher" on a schedule enumerates work units and pushes them to SQS; workers process batches.
functions:
dispatchNightly:
handler: src/jobs/dispatch.handler
timeout: 300
events:
- schedule: rate(1 day)
environment:
QUEUE_URL: !Ref WorkQueue
nightlyWorker:
handler: src/jobs/worker.handler
timeout: 300
events:
- sqs:
arn: !GetAtt WorkQueue.Arn
batchSize: 10
maximumConcurrency: 20 # protect the downstream database
functionResponseType: ReportBatchItemFailures
maximumConcurrency on the event source is the knob that stops a nightly job from saturating a Postgres instance, and ReportBatchItemFailures means one poisoned record does not replay a whole batch.
2. Orchestrate with Step Functions. For multi-phase jobs with checkpoints, targeting a state machine from the schedule gives you a year of runtime, built-in retries, a Distributed Map for large fan-outs, and an execution history you can actually debug:
# with serverless-step-functions
stepFunctions:
stateMachines:
monthlyClose:
events:
- schedule:
rate: cron(0 3 1 * ? *) # 03:00 UTC on the 1st
definition:
StartAt: Extract
States:
Extract: { Type: Task, Resource: !GetAtt extract.Arn, Next: Transform }
Transform: { Type: Task, Resource: !GetAtt transform.Arn, Next: Publish }
Publish: { Type: Task, Resource: !GetAtt publish.Arn, End: true }
3. Run a container. Some jobs are genuinely one long single-threaded grind — a 40-minute database dump, a large pandas transform. Have the scheduled Lambda call ecs:RunTask on a Fargate task (or have EventBridge Scheduler target ECS directly) and keep the orchestration serverless while the compute is not. You pay per second, the code stays in the same repo, and nothing has to be rewritten to fit an arbitrary limit.
The four operational details that actually cause incidents
Idempotency. Both schedulers guarantee at-least-once delivery. A rule can fire twice; a retry can re-invoke. Every job must be safe to run twice — a conditional write keyed on the job's logical run:
await ddb.send(new PutItemCommand({
TableName: process.env.RUNS_TABLE,
Item: { pk: { S: `run#${jobName}#${runDate}` }, startedAt: { S: now } },
ConditionExpression: 'attribute_not_exists(pk)',
})); // ConditionalCheckFailedException => already ran, exit 0
Retries and a DLQ. Asynchronous Lambda invocations retry twice by default, then the event is gone unless you configured somewhere for it to land. Give scheduled functions an explicit destination:
functions:
nightlyReconcile:
handler: src/jobs/reconcile.handler
maximumRetryAttempts: 1
onError: !Ref JobDLQ
Set maximumRetryAttempts: 0 for jobs where a duplicate run is worse than a missed run, and page on DLQ depth instead.
Monitor for jobs that did not run. The failure mode nobody catches is silence: a disabled rule, a deleted schedule, a handler that exits early. Alarm on the absence of success, not just on errors. Emit a metric at the end of each successful run and alarm when it is missing:
resources:
Resources:
ReconcileMissingAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: reconcile-did-not-complete-${sls:stage}
Namespace: Jobs
MetricName: ReconcileCompleted
Statistic: Sum
Period: 86400
EvaluationPeriods: 1
Threshold: 1
ComparisonOperator: LessThanThreshold
TreatMissingData: breaching
AlarmActions: [!Ref OpsTopic]
TreatMissingData: breaching is the whole point — no data means the job never finished.
Concurrency and overlap. A job scheduled every five minutes that sometimes takes six will overlap itself. Either make overlap safe, or take a lock (a conditional DynamoDB write with a TTL), or set reservedConcurrency: 1 on the function so only one copy runs at a time.
Timezone and expression quick reference
- EventBridge rules:
cron(minute hour day-of-month month day-of-week year), six fields, UTC only,?for "no specific value" in one of the day fields. - EventBridge Scheduler: same six-field cron plus
rate(n unit)and one-timeat(yyyy-mm-ddThh:mm:ss), with an optional IANA time zone. rate(1 day)is "every 24 hours from deploy", not "midnight". If the hour matters, usecron().- Neither service guarantees the exact second. Expect up to a minute of jitter, and never build a job that depends on firing before another job's clock.
Migration notes from V3-era projects
If you are bringing an older service forward, two habits are worth retiring: scheduled "warmer" pings (measure first — see our cold start guide), and homegrown "pending jobs" tables with a one-minute sweeper rule. Most sweeper tables become a handful of EventBridge Scheduler calls with ActionAfterCompletion: DELETE, which deletes both the table and the sweeper.
Checklist before you call it done
- Every scheduled rule is named per stage and disabled outside production unless it is meant to run there.
- Every job is idempotent, with a conditional write or natural idempotency key.
- Every job has a DLQ or on-failure destination, and an alarm on DLQ depth.
- Every job has a "did not complete" alarm with
TreatMissingData: breaching. - Anything approaching 15 minutes is fanned out, orchestrated, or containerised — not given a bigger timeout.
- Per-entity schedules use EventBridge Scheduler with a time zone, not UTC cron plus offset arithmetic.
Scheduled work is where serverless applications quietly accumulate risk, because nothing fails visibly until a month-end close does not run. If you would like a review of your job layer — or help moving a long-running batch job off a cron box and onto this pattern — our DevOps for Serverless Apps and Serverless Architecture Design teams do this work regularly. Get in touch.