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.
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 viaserverless loginor 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(Date.now() / 1000) + 14 * 86_400) },
},
ConditionExpression: 'attribute_not_exists(pk)',
}),
);
} catch (err) {
if (err instanceof ConditionalCheckFailedException) {
console.log(JSON.stringify({ msg: 'already done', job: event.job, businessDay }));
return { status: 'duplicate', businessDay };
}
throw err;
}
// The real work happens here, e.g. sending reminders
// for all rentals due back on businessDay.
console.log(JSON.stringify({ msg: 'running job', job: event.job, businessDay, attempt: event.attempt }));
return { status: 'sent', businessDay };
}
In a real system, you would mark the item as “done” only after a successful send, or send each email idempotently. For the example, the simple variant is enough because it shows the principle.
Step 6: Test the clock change before it happens
Waiting for next March is not a test strategy. With the built-in Node.js test runner and tsx, you can check the critical days right away:
// test/local-time.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { localDate, planDailyRuns, wallClockInstants } from '../src/local-time';
const ZONE = 'Europe/Berlin';
test('shortly before midnight UTC it is already the next day in Berlin', () => {
assert.equal(localDate('2026-10-29T23:30:00Z', ZONE), '2026-10-30');
assert.equal(localDate('2026-07-14T22:15:00Z', ZONE), '2026-07-15');
});
test('7:30 a.m. in Berlin moves in UTC with the clock change', () => {
const runs = planDailyRuns('2026-10-24', 3, 7, 30, ZONE);
assert.deepEqual(runs, [
{ date: '2026-10-24', status: 'runs', utc: '2026-10-24T05:30:00.000Z' },
{ date: '2026-10-25', status: 'runs', utc: '2026-10-25T06:30:00.000Z' },
{ date: '2026-10-26', status: 'runs', utc: '2026-10-26T06:30:00.000Z' },
]);
});
test('2:30 a.m. does not exist when summer time starts: run is skipped', () => {
assert.deepEqual(wallClockInstants('2026-03-29', 2, 30, ZONE), []);
const runs = planDailyRuns('2026-03-28', 3, 2, 30, ZONE);
assert.deepEqual(runs.map((r) => r.status), ['runs', 'skipped', 'runs']);
});
test('2:30 a.m. occurs twice when summer time ends: only one run', () => {
const instants = wallClockInstants('2026-10-25', 2, 30, ZONE).map((t) => new Date(t).toISOString());
assert.deepEqual(instants, ['2026-10-25T00:30:00.000Z', '2026-10-25T01:30:00.000Z']);
const [run] = planDailyRuns('2026-10-25', 1, 2, 30, ZONE);
assert.deepEqual(run, { date: '2026-10-25', status: 'runs', utc: '2026-10-25T00:30:00.000Z' });
});
Add the script "test": "tsx --test test/*.test.ts" to your package.json and run the tests:
npm test
At the end you should see # pass 4 and # fail 0. A small preview script also shows you when the job runs in UTC around the clock change:
// src/preview.ts
// Usage: npx tsx src/preview.ts 2026-10-23 5 7 30 Europe/Berlin
import { planDailyRuns } from './local-time';
const [from = '2026-10-23', days = '5', hour = '7', minute = '30', zone = 'Europe/Berlin'] = process.argv.slice(2);
for (const run of planDailyRuns(from, Number(days), Number(hour), Number(minute), zone)) {
console.log(run.status === 'runs' ? `${run.date} runs at ${run.utc}` : `${run.date} skipped`);
}
2026-10-23 runs at 2026-10-23T05:30:00.000Z
2026-10-24 runs at 2026-10-24T05:30:00.000Z
2026-10-25 runs at 2026-10-25T06:30:00.000Z
2026-10-26 runs at 2026-10-26T06:30:00.000Z
2026-10-27 runs at 2026-10-27T06:30:00.000Z
In UTC the run shifts by one hour, while in Berlin it stays at 7:30 a.m. That is exactly what you want to see.
Step 7: Deploy and check the schedule
serverless deploy
aws scheduler get-schedule --name bike-rental-return-reminder --query '{expr:ScheduleExpression,tz:ScheduleExpressionTimezone,state:State}'
Expect output with "tz": "Europe/Berlin" and "state": "ENABLED". In the AWS console, Scheduler also shows you the next invocation times. For testing, you can invoke the function directly with a matching event:
serverless invoke -f returnReminders --data '{"job":"return-reminder","scheduledTime":"2026-10-29T23:30:00Z"}'
The response contains "businessDay": "2026-10-30", even though the instant is still October 29 in UTC. A second call with the same data returns "status": "duplicate".
Common time zone mistakes with Scheduler
Jobs between 2:00 and 3:00 a.m. local time
This hour disappears completely once every March. Schedule anything that must run every day outside this window, or use UTC right away.
Fixed offsets instead of zone names
Via CLI or SDK, Scheduler also accepts formats such as UTC+1. A fixed offset knows nothing about daylight saving time, and the console then shows no next invocation dates. Always use the IANA name, such as Europe/Berlin.
Date calculated in UTC
See step 4: if you use toISOString() for the date inside Lambda, you work for the wrong day at night. Always calculate with the scheduled time and the business time zone.
No plan for failures
Without a dead-letter queue, an invocation that finally fails disappears silently. Set a retry policy that suits your job and monitor the queue with an alarm.
One-time schedules never cleaned up
According to the documentation, schedules with at() still count against your quota after they have run. Delete them afterwards, otherwise thousands of leftovers pile up when you create many schedules dynamically.
What does EventBridge Scheduler cost?
You pay per invocation. According to the pricing page, 14 million invocations per month are included in the Free Tier, and in the US West pricing example each additional million costs USD 1.00. The two schedules in this article come to roughly 30 invocations a month and stay free. On top of that come the usual costs for Lambda, DynamoDB and SQS, which are a matter of cents at this volume.
My take
I would no longer create scheduled EventBridge rules for new projects. Scheduler can do everything the rules can, plus time zones, one-time schedules, a retry policy and a dead-letter queue per schedule. According to the Serverless docs, the limit is also much higher: 300 rules per event bus versus one million schedules.
Where I disagree: “everything in local time” is just as wrong as “everything in UTC”. Technical jobs such as nightly cleanup or exports belong in UTC, because then they are always the same distance apart and no hour gets skipped. I use local time only where a human expects a certain time of day. And I find it a pity that the Serverless Framework still defaults to the classic rule for the schedule event, because that is exactly how beginners walk into the trap this article is about.
FAQ
Which time zone does EventBridge Scheduler use by default?
UTC. Only when you set ScheduleExpressionTimezone, or timezone in the Serverless Framework, does Scheduler use another zone.
Does EventBridge Scheduler handle daylight saving time?
Yes, for cron and one-time schedules in a zone that observes daylight saving time. A time that does not exist in spring is skipped, and a repeated time in autumn runs only once.
Can I set a time zone on regular EventBridge rules?
No. Scheduled rules on the event bus always run in UTC. For local times you need EventBridge Scheduler.
How do I set a time zone in the Serverless Framework?
In the schedule event, set method: scheduler and timezone: Europe/Berlin. For a retry policy and dead-letter queue, define the AWS::Scheduler::Schedule resource yourself under resources.
Your next step
Search your projects for schedule: events without method: scheduler and for cron expressions that mean a time of day for people. Every hit is a candidate for migration, ideally now, while the clock change is still fresh in everyone’s mind. If you ever need to reprocess events after a bug, take a look at EventBridge Replay as well.
Which of your jobs still run in UTC even though they really mean a local time? Let me know in the comments.
If you need one schedule per booking instead of recurring jobs, continue with EventBridge Scheduler one-time schedule for every booking.
Sources
- AWS: Schedule types in EventBridge Scheduler (time zones, daylight saving time)
- AWS: Configuring flexible time windows in EventBridge Scheduler
- AWS: Adding context attributes in EventBridge Scheduler
- AWS CloudFormation: AWS::Scheduler::Schedule
- AWS re:Post: How do I adjust my EventBridge schedule for daylight saving time?
- AWS: Amazon EventBridge pricing
- Serverless Framework: Schedule events
This post was created with AI assistance.
👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.
Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)