+1 (726) 207-9872

Scheduled Jobs and Cron on Serverless: EventBridge Rules, EventBridge Scheduler, and Long-Running Batch with Serverless Framework V4

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 inserverless.yml under a function's eventsAPI/SDK at runtime, or IaC for fixed schedules
Good forA fixed set of app-wide jobsThousands of dynamic, per-entity, often one-off schedules
Time zonesUTC onlyNamed time zones with DST handling
One-time schedulesNoYes (at(...) expressions)
Flexible time windowsNoYes, spread invocations over a window
TargetsLambda, Step Functions, SQS, and moreOver 270 API actions, including Lambda and Step Functions
Delete after runManualActionAfterCompletion: 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, dev and prod rules are obvious and never collide.
  • input beats 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-time at(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, use cron().
  • 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.