Setting the right EventBridge Scheduler time zone takes exactly one line of configuration – and saves you the twice-a-year ticket that says “The reminder arrived an hour early today”. Last Sunday, October 25, Germany turned its clocks back again. Since then, plenty of scheduled Lambda functions run an hour off, because they are still triggered by classic EventBridge rules in UTC.

This topic keeps my attention because it looks so harmless. A cron expression is written in seconds, and all summer everything is fine. Only in autumn does someone from the business side notice that the email now arrives at 6:30 a.m. So in this article you will build a job with the Serverless Framework and TypeScript that reliably runs at 7:30 a.m. German time. You will also test the clock change before it surprises you in production.

In short

EventBridge Scheduler evaluates cron and one-time schedules in an IANA time zone such as Europe/Berlin and handles daylight saving time automatically, while classic EventBridge rules always run in UTC. The biggest pitfall then sits in your own code: Lambda runs in UTC, so you have to derive the business day in the right zone yourself. Times between 2:00 and 3:00 a.m. are skipped once every spring.

What you will learn in this article

  • Why classic EventBridge rules drift when the clocks change
  • How Scheduler handles time zones, daylight saving time and flexible time windows
  • How to set up a cron job in local time with the Serverless Framework
  • How to derive the business day from UTC correctly in TypeScript
  • How to reproduce the clock change in tests instead of waiting for March

Why classic EventBridge rules drift when the clocks change

Before EventBridge Scheduler existed, scheduled Lambda functions were usually triggered by a scheduled rule on the default event bus (in the Serverless Framework, this is still the default for the schedule event). These rules know only one time zone: UTC. AWS says so quite plainly in its Knowledge Center and explicitly recommends Scheduler for daylight saving time.

Here is what that means in practice for a rule with cron(30 5 ? * MON-SAT *). In summer, Germany is on UTC+2, so the rule fires at 7:30 a.m. Since October 25, Germany is back on UTC+1, and the same rule fires at 6:30 a.m. Nobody changed anything, yet the behavior is different.

Comparison: UTC rule starts at 6:30 from Oct 25, Scheduler with time zone Europe/Berlin stays at 7:30

A classic rule in UTC shifts by one hour after the clock change, while Scheduler with a time zone stays at 7:30 a.m.

You probably know the usual workarounds: two rules that you switch in March and October, or a Lambda function that runs every hour and checks whether it is 7:30 a.m. yet. Both work, and both are maintenance you can avoid. EventBridge Scheduler accepts a time zone from the IANA time zone database (the list with names such as Europe/Berlin or America/New_York) and converts daylight saving time on its own.

How the EventBridge Scheduler time zone handles daylight saving time

Scheduler supports three kinds of schedules. rate() repeats at fixed intervals, cron() runs at specific times of day, and at() runs exactly once. You set the time zone in the ScheduleExpressionTimezone field, and it applies to cron and one-time schedules. Without it, Scheduler also uses UTC.

Things get tricky on the two days of the clock change. The documentation describes the behavior clearly, and it is worth walking through both cases once.

What happens when the time is skipped in spring?

On March 29, 2026, German clocks jumped from 2:00 to 3:00 a.m. A schedule with cron(30 2 * * ? *) in Europe/Berlin could not run on that day, because 2:30 a.m. simply did not exist. Scheduler skips the run and continues normally the next day. For a nightly report that may not matter, for a daily billing job it probably does.

Does the job run twice in autumn?

No. On October 25, clocks went from 2:59 back to 2:00 a.m., so 2:30 a.m. happened twice. Scheduler runs the schedule only once, at the first occurrence before the change. After that, everything continues as usual.

Why rate(1 days) still drifts

For rate() with the unit days, Scheduler strictly counts 24 hours from the previous run. A day with 23 or 25 hours therefore shifts the job by one hour in local time. When a time of day matters, use cron() with a time zone.

How precise is Scheduler?

All schedules run with 60-second precision: a schedule for 7:30 a.m. starts some time between 7:30:00 and 7:30:59. With a flexible time window (mode FLEXIBLE), you additionally let Scheduler spread the invocation across 1 to 1,440 minutes after the scheduled time. That smooths out load peaks when many jobs start on the hour.

As a rule of thumb, I use this table:

Kind of job Time zone Expression
Reminder, report, anything people expect at a certain time Europe/Berlin (your users’ zone) cron()
Technical maintenance, cleanup, exports without a fixed time UTC rate() or cron()
Exactly one appointment, e.g. a reservation expiring Zone in which the appointment was made at()
Spreading load matters more than the exact minute any FLEXIBLE with 5–15 minutes

The example: return reminders for a bike rental shop

The example is a fictional bike rental shop. Monday to Saturday at 7:30 a.m., a Lambda function reminds every customer who has to return their bike that day. On the first Monday of the month at 9:00 a.m., a monthly report runs as well, and it may start a few minutes late.

Two details make the example realistic. First, Scheduler passes the scheduled time to the Lambda function so it knows which day it is working for. Second, an idempotency item in DynamoDB prevents emails from going out twice after a retry. Idempotent means the same call may arrive several times, but its effect happens only once.

🎬 Flow: EventBridge Scheduler invokes Lambda at local time – this animated diagram only runs on my site: watch it on sascha-ms.de

Step by step: EventBridge Scheduler time zone with the Serverless Framework

Prerequisites

  • Node.js 22 or later and npm
  • Serverless Framework v4 (npm i -g serverless), signed in via serverless login or with a license key
  • An AWS account with credentials that may create CloudFormation, Lambda, IAM, DynamoDB, SQS and Scheduler resources
  • Basic knowledge of TypeScript and Lambda

Step 1: Create the project

Create an empty folder and install the dependencies. Serverless Framework v4 builds TypeScript itself with esbuild, so you do not need a separate build step.

mkdir bike-rental-reminders && cd bike-rental-reminders npm init -y npm i @aws-sdk/client-dynamodb npm i -D typescript tsx @types/node @types/aws-lambda mkdir src test 

Step 2: The quick variant with the schedule event

For many cases, the built-in schedule event is enough. The important part is method: scheduler; otherwise the framework creates a classic rule in UTC. Only with it do you get the timezone field.

# serverless.yml (excerpt: quick variant) functions: returnReminders: handler: src/handler.handler events: - schedule: method: scheduler rate: cron(30 7 ? * MON-SAT *) timezone: Europe/Berlin input: job: return-reminder 

The framework creates a resource of type AWS::Scheduler::Schedule plus a role for it. However, the docs show no settings for a retry policy, dead-letter queue or flexible time window in this variant, and the input is static. For a production job, I prefer to write the resource myself.

Step 3: The complete serverless.yml

This is the variant I would take to production. It contains the function, a DynamoDB table for idempotency, a dead-letter queue, a dedicated role for Scheduler and the two schedules.

# serverless.yml service: bike-rental-reminders frameworkVersion: '4' provider: name: aws runtime: nodejs22.x architecture: arm64 region: eu-central-1 environment: RUNS_TABLE: { Ref: RunsTable } BUSINESS_TIME_ZONE: Europe/Berlin iam: role: statements: - Effect: Allow Action: dynamodb:PutItem Resource: { 'Fn::GetAtt': [RunsTable, Arn] } functions: returnReminders: handler: src/handler.handler timeout: 30 resources: Resources: RunsTable: Type: AWS::DynamoDB::Table Properties: BillingMode: PAY_PER_REQUEST AttributeDefinitions: - { AttributeName: pk, AttributeType: S } KeySchema: - { AttributeName: pk, KeyType: HASH } TimeToLiveSpecification: AttributeName: expiresAt Enabled: true ReminderDeadLetterQueue: Type: AWS::SQS::Queue Properties: MessageRetentionPeriod: 1209600 # 14 days 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: { Ref: AWS::AccountId } Policies: - PolicyName: invoke-reminder-function PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: lambda:InvokeFunction Resource: { 'Fn::GetAtt': [ReturnRemindersLambdaFunction, Arn] } - Effect: Allow Action: sqs:SendMessage Resource: { 'Fn::GetAtt': [ReminderDeadLetterQueue, Arn] } # Monday to Saturday at 7:30 a.m. German time, summer or winter ReturnReminderSchedule: Type: AWS::Scheduler::Schedule Properties: Name: bike-rental-return-reminder Description: Reminds customers to return their bike the same day ScheduleExpression: cron(30 7 ? * MON-SAT *) ScheduleExpressionTimezone: Europe/Berlin FlexibleTimeWindow: Mode: 'OFF' Target: Arn: { 'Fn::GetAtt': [ReturnRemindersLambdaFunction, Arn] } RoleArn: { 'Fn::GetAtt': [SchedulerInvokeRole, Arn] } Input: '{"job":"return-reminder","scheduledTime":"<aws.scheduler.scheduled-time>","attempt":"<aws.scheduler.attempt-number>"}' RetryPolicy: MaximumRetryAttempts: 3 MaximumEventAgeInSeconds: 3600 DeadLetterConfig: Arn: { 'Fn::GetAtt': [ReminderDeadLetterQueue, Arn] } # First Monday of the month at 9:00 a.m., may spread until 9:15 a.m. MonthlyReportSchedule: Type: AWS::Scheduler::Schedule Properties: Name: bike-rental-monthly-report ScheduleExpression: cron(0 9 ? * 2#1 *) ScheduleExpressionTimezone: Europe/Berlin FlexibleTimeWindow: Mode: FLEXIBLE MaximumWindowInMinutes: 15 Target: Arn: { 'Fn::GetAtt': [ReturnRemindersLambdaFunction, Arn] } RoleArn: { 'Fn::GetAtt': [SchedulerInvokeRole, Arn] } Input: '{"job":"monthly-report","scheduledTime":"<aws.scheduler.scheduled-time>"}' RetryPolicy: MaximumRetryAttempts: 2 MaximumEventAgeInSeconds: 7200 DeadLetterConfig: Arn: { 'Fn::GetAtt': [ReminderDeadLetterQueue, Arn] } 

A few places deserve a second look. The Input field contains the placeholders <aws.scheduler.scheduled-time> and <aws.scheduler.attempt-number>. Scheduler replaces them on every invocation with the scheduled time in UTC and the attempt number. The expression 2#1 in the monthly report means “first Monday”, because Scheduler counts weekdays from Sunday as 1. If you prefer to try cron expressions first, my EventBridge cron explainer helps.

The aws:SourceAccount condition in the trust policy makes sure that only schedules from your own account can assume the role. On top of that, the role may invoke exactly one function and write to exactly one queue.

Step 4: Derive the business day from UTC correctly

Now comes a mistake that is very easy to make. Lambda runs in UTC, so new Date().toISOString().slice(0, 10) returns the UTC date. A job that starts at 12:30 a.m. German time therefore works for the wrong day. The following file converts a UTC instant into a zone and also mimics how Scheduler handles missing and repeated times.

// src/local-time.ts // Helpers to convert UTC instants into an IANA time zone. const formatters = new Map<string, Intl.DateTimeFormat>(); function formatterFor(timeZone: string): Intl.DateTimeFormat { let f = formatters.get(timeZone); if (!f) { f = new Intl.DateTimeFormat('en-CA', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }); formatters.set(timeZone, f); } return f; } export interface LocalParts { year: number; month: number; day: number; hour: number; minute: number; } export function localParts(utcMillis: number, timeZone: string): LocalParts { const parts = Object.fromEntries( formatterFor(timeZone) .formatToParts(new Date(utcMillis)) .filter((p) => p.type !== 'literal') .map((p) => [p.type, Number(p.value)]), ); return { year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: parts.minute, }; } /** Local date (YYYY-MM-DD) of a UTC instant in the given zone. */ export function localDate(isoUtc: string, timeZone: string): string { const p = localParts(Date.parse(isoUtc), timeZone); const pad = (n: number) => String(n).padStart(2, '0'); return `${p.year}-${pad(p.month)}-${pad(p.day)}`; } /** Offset of the zone from UTC in milliseconds at a given instant. */ function offsetMillis(utcMillis: number, timeZone: string): number { const p = localParts(utcMillis, timeZone); const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute); return asUtc - Math.floor(utcMillis / 60_000) * 60_000; } /** * All UTC instants at which the wall clock shows exactly hour:minute * on a local date. Empty = the time does not exist (start of summer time), * two hits = the time occurs twice (end of summer time). */ export function wallClockInstants( date: string, hour: number, minute: number, timeZone: string, ): number[] { const [y, m, d] = date.split('-').map(Number); const guess = Date.UTC(y, m - 1, d, hour, minute); const offsets = new Set( [-36, -12, 0, 12, 36].map((h) => offsetMillis(guess + h * 3_600_000, timeZone)), ); const hits = [...offsets] .map((o) => guess - o) .filter((t) => { const p = localParts(t, timeZone); return p.year === y && p.month === m && p.day === d && p.hour === hour && p.minute === minute; }); return [...new Set(hits)].sort((a, b) => a - b); } export type PlannedRun = | { date: string; status: 'runs'; utc: string } | { date: string; status: 'skipped' }; /** * Mimics how EventBridge Scheduler runs a daily cron * cron(minute hour * * ? *) in a time zone: * non-existent time = run is skipped, repeated time = only one run. */ export function planDailyRuns( fromDate: string, days: number, hour: number, minute: number, timeZone: string, ): PlannedRun[] { const start = Date.parse(`${fromDate}T12:00:00Z`); const runs: PlannedRun[] = []; for (let i = 0; i < days; i++) { const date = new Date(start + i * 86_400_000).toISOString().slice(0, 10); const instants = wallClockInstants(date, hour, minute, timeZone); runs.push( instants.length === 0 ? { date, status: 'skipped' } : { date, status: 'runs', utc: new Date(instants[0]).toISOString() }, ); } return runs; } 

The wallClockInstants function finds all UTC instants at which the wall clock shows a given time on a given day. An empty list means the time does not exist. With two hits it occurs twice, and planDailyRuns takes only the first one, just like Scheduler. If you already use the Temporal API on Node.js, this gets more elegant; I show how in Temporal API in TypeScript.

Step 5: Make the handler idempotent

The handler derives the business day from the scheduled time and then writes an item with the key job#date. If the item already exists, an earlier attempt succeeded and the handler stops. I described the same pattern with TTL in more detail in DynamoDB TTL for reservations.

// src/handler.ts import { ConditionalCheckFailedException, DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; import { localDate } from './local-time'; const ddb = new DynamoDBClient({}); const TABLE = process.env.RUNS_TABLE ?? 'bike-rental-reminder-runs'; const ZONE = process.env.BUSINESS_TIME_ZONE ?? 'Europe/Berlin'; export interface ReminderEvent { job: string; scheduledTime?: string; // comes from <aws.scheduler.scheduled-time>, e.g. 2026-10-30T06:30:00Z attempt?: string; } export async function handler(event: ReminderEvent) { const scheduledTime = event.scheduledTime ?? new Date().toISOString(); // Important: derive the business day in the business time zone, not in UTC. const businessDay = localDate(scheduledTime, ZONE); try { // Idempotency: exactly one item per job and business day. await ddb.send( new PutItemCommand({ TableName: TABLE, Item: { pk: { S: `${event.job}#${businessDay}` }, scheduledTime: { S: scheduledTime }, expiresAt: { N: String(Math.floor(<...
Source:https://dev.to/sascha-ms/eventbridge-scheduler-time-zone-cron-at-local-time-3ol0