An EventBridge Scheduler one-time schedule solves a problem that shows up in almost every booking system. At a specific moment, exactly one thing should happen, and it should happen per booking. What keeps bugging me is how often people still build a cron job for that. It then asks the database every minute whether anything is due. That works. Still, it is an endless loop for an event that happens exactly once per order.
In this article I build the alternative for a fictional bakery with pre-orders. Customers order bread rolls for a pickup time, and one hour before that they get a reminder. If they rebook, the reminder moves along, and if they cancel, there is no reminder at all. I already covered recurring cron schedules in local time in EventBridge Scheduler time zone. This post is about the counterpart: dynamic dates that your code creates at runtime and cleans up again.
In short
For every booking you create an at() schedule via the SDK. It invokes a Lambda exactly once and then removes itself thanks to ActionAfterCompletion DELETE. Deterministic names derived from the booking ID make rebooking and cancelling easy. Because UpdateSchedule replaces every field, you always send the complete request.
What you will learn in this article
- Computing one-time dates with at() and time zones correctly
- Creating, rebooking and cancelling schedules idempotently
- Setting up IAM properly, including PassRole and the trust policy
- When Scheduler, DynamoDB TTL or a Step Functions Wait fits better
What exactly is an EventBridge Scheduler one-time schedule?
EventBridge Scheduler is the AWS scheduling service. At defined times it invokes a target, for example a Lambda function, an SQS queue or one of more than 6,000 API operations. Besides recurring schedules with rate() and cron(), it knows a third type: the one-time schedule. You describe it with an at expression of the form at(yyyy-mm-ddThh:mm:ss) and, optionally, a time zone.
A few quirks are worth knowing before you start:
- Precision is one minute. So a schedule set for 07:00 fires somewhere between 07:00:00 and 07:00:59, as long as no flexible time window is set.
- For one-time schedules the service ignores
StartDateandEndDate. Only the at expression defines the moment. - A completed one-time schedule sticks around and keeps counting against your quota. That is why
ActionAfterCompletionexists: with the valueDELETE, the schedule cleans itself up after the invocation. - Delivery is “at least once”. Your target therefore has to cope with a duplicate invocation.
The difference from a classic cron job is the point of view. You no longer ask on a regular basis whether something is due. Instead, you register the wake-up call right when the booking happens. For lots of small individual dates, that is much nicer than a table that gets scanned every minute.
When should you use Scheduler, DynamoDB TTL or a Step Functions Wait?
For “do something at time X”, AWS offers at least three tools. At first glance they look interchangeable. In the details, however, they behave very differently.
Three ways to a timed invocation and what each one is good for
| Criterion | Scheduler with at() | DynamoDB TTL | Step Functions Wait |
|---|---|---|---|
| Precision | To the minute | Deletion usually within a few days | Waits until the given timestamp |
| Limits | 10M schedules per Region (adjustable) | No fixed point in time | Wait at most one year (Standard) or five minutes (Express) |
| Cancellation | DeleteSchedule |
Delete the item or change the TTL attribute | Stop the execution |
| Running parts | One schedule per date | Stream plus Lambda to react | One open execution per date |
DynamoDB TTL is a cleanup tool
I described how TTL works for reservations in DynamoDB TTL: seat holds with an expiry date. According to the docs, DynamoDB usually removes expired items only some days after they expire. For “the reservation lapses at some point” that is ideal, and it does not cost any write capacity. For “reminder at 07:00”, on the other hand, it is useless.
A Step Functions Wait pays off with several steps
A Wait state in Step Functions is punctual and, in a Standard workflow, can wait for up to a year. Express workflows, by contrast, manage five minutes at most. This pays off when several steps follow the wait, such as checking the payment, reserving stock and notifying the customer. For a single invocation, however, one open execution per booking is heavyweight. In addition, the default limit is one million open executions per account and Region.
The scheduler sits right in between
The scheduler is punctual enough for reminders and needs no running process. It is also built for very many small dates. My rule of thumb is therefore simple: one date, one invocation, no logic in between, then Scheduler. As soon as real workflow logic sits between waiting and invoking, I reach for Step Functions.
What does the architecture for the bakery look like?
The example is deliberately small, and every order gets exactly one EventBridge Scheduler one-time schedule. An order Lambda accepts three actions: create, rebook and cancel. It computes the reminder time and talks to the Scheduler API. At the given time, the scheduler invokes a second Lambda that sends the reminder. Anything that cannot be delivered ends up in a dead-letter queue (DLQ), which is an SQS queue for failed invocations.
🎬 Flow: one-time schedule with EventBridge Scheduler – this animated diagram only runs on my site: watch it on sascha-ms.de
All schedules of the application live in their own schedule group called bakery-reminders. A schedule group is something like a folder for schedules. With the scheduler, you assign tags at the group level, not per schedule. You can also scope permissions and the trust policy to exactly this group. Be careful when cleaning up, though: if you delete the group, the service deletes all schedules inside it as well.
How to build the EventBridge Scheduler one-time schedule with CDK
Prerequisites
- Node.js 22 or newer and npm
- Basic knowledge of TypeScript and AWS CDK v2 (tested with aws-cdk-lib 2.275.0 and CDK CLI 2.1147.0)
- For tests and
cdk synthyou need neither Docker nor an AWS account, only the deployment needs a bootstrapped account
Step 1: Create the project
Create an empty folder and install the packages. I pin the versions I used to verify the code.
mkdir bakery-reminders && cd bakery-reminders
npm init -y && npm pkg set type=module
npm install aws-cdk-lib@2.275.0 constructs@10.8.1 @aws-sdk/client-scheduler@3.1161.0
npm install -D aws-cdk@2.1147.0 typescript@7.0.2 tsx@4.23.15 esbuild@0.28.2 @types/node@26.6.6
The locally installed esbuild matters. If NodejsFunction finds it in the project, CDK bundles without Docker. After that, add three small configuration files.
// package.json
{
"name": "bakery-reminders",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test test/*.test.ts",
"synth": "cdk synth"
},
"dependencies": {
"@aws-sdk/client-scheduler": "^3.1161.0",
"aws-cdk-lib": "^2.275.0",
"constructs": "^10.8.1"
},
"devDependencies": {
"@types/node": "^26.6.6",
"aws-cdk": "^2.1147.0",
"esbuild": "^0.28.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2"
}
}
// tsconfig.json
{
"compilerOptions": {
"target": "ES2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["bin", "lib", "src", "test"]
}
// cdk.json
{
"app": "npx tsx bin/app.ts"
}
Step 2: Compute the point in time
A customer picks “December 24, 08:00” in Berlin. From that, you derive a reminder time one hour earlier. Here you have two options. You can write the local time straight into the at expression and set ScheduleExpressionTimezone to Europe/Berlin. That is convenient as long as you only pass the time through.
As soon as you calculate, things get tricky. In the night of a DST change, some clock times exist twice and others not at all. That is why I calculate with real instants. First the local pickup time becomes a UTC instant, then I subtract the duration. Next, the at expression goes to the scheduler in UTC, and I explicitly set ScheduleExpressionTimezone to UTC. The customer's time zone still travels along in the input, so the message can name the correct local time later.
// src/schedule.ts
import { createHash } from "node:crypto";
// Allowed characters per API reference: Name [0-9a-zA-Z-_.]{1,64}, ClientToken [a-zA-Z0-9-_]{1,64}
const NAME_PATTERN = /^[0-9a-zA-Z\-_.]{1,64}$/;
const TOKEN_PATTERN = /^[a-zA-Z0-9\-_]{1,64}$/;
const LOCAL_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
const NAME_PREFIX = "pickup-";
export class ScheduleError extends Error {}
type Fields = [number, number, number, number, number, number];
function fieldsOf(local: string): Fields {
const m = LOCAL_PATTERN.exec(local);
if (!m) throw new ScheduleError(`Invalid format: ${local} (expected YYYY-MM-DDThh:mm)`);
const f = m.slice(1, 7).map((x) => Number(x ?? 0)) as Fields;
const probe = new Date(Date.UTC(f[0], f[1] - 1, f[2], f[3], f[4], f[5]));
if (probe.getUTCMonth() !== f[1] - 1 || probe.getUTCDate() !== f[2] || f[3] > 23 || f[4] > 59) {
throw new ScheduleError(`Not a valid date: ${local}`);
}
return f;
}
// Returns the wall-clock time of an instant in an IANA time zone as "YYYY-MM-DDThh:mm:ss"
export function wallClock(instant: Date, timeZone: string): string {
const parts = 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",
}).formatToParts(instant);
const p = (type: string) => parts.find((x) => x.type === type)?.value ?? "00";
return `${p("year")}-${p("month")}-${p("day")}T${p("hour")}:${p("minute")}:${p("second")}`;
}
function offsetMs(instant: Date, timeZone: string): number {
const w = fieldsOf(wallClock(instant, timeZone));
return Date.UTC(w[0], w[1] - 1, w[2], w[3], w[4], w[5]) - instant.getTime();
}
// Local time + time zone -> unambiguous instant (UTC).
// Non-existent times (spring-forward gap) are rejected,
// repeated times (fall-back) resolve to the earlier instant.
export function localToInstant(local: string, timeZone: string): Date {
const f = fieldsOf(local);
const naive = Date.UTC(f[0], f[1] - 1, f[2], f[3], f[4], f[5]);
const day = 24 * 60 * 60 * 1000;
const offsets = new Set([
offsetMs(new Date(naive - day), timeZone),
offsetMs(new Date(naive), timeZone),
offsetMs(new Date(naive + day), timeZone),
]);
const wanted = wallClock(new Date(naive), "UTC");
const matches = [...offsets]
.map((o) => new Date(naive - o))
.filter((candidate) => wallClock(candidate, timeZone) === wanted)
.sort((a, b) => a.getTime() - b.getTime());
if (matches.length === 0) {
throw new ScheduleError(`${local} does not exist in ${timeZone} (DST change)`);
}
return matches[0];
}
// at() expression without milliseconds, e.g. at(2026-12-24T07:00:00)
export function atExpression(instant: Date, timeZone = "UTC"): string {
return `at(${wallClock(instant, timeZone)})`;
}
export type Plan =
| { kind: "create"; remindAt: Date; expression: string; timeZone: "UTC" }
| { kind: "skip"; reason: string };
// Computes the reminder time: pickup minus lead time, calculated as a real duration
export function planReminder(
pickupLocal: string,
timeZone: string,
leadMinutes: number,
now: Date,
minGapSeconds = 120,
): Plan {
if (!Number.isInteger(leadMinutes) || leadMinutes < 0) {
throw new ScheduleError("Lead time must be an integer >= 0");
}
const pickup = localToInstant(pickupLocal, timeZone);
if (pickup.getTime() <= now.getTime()) {
throw new ScheduleError("Pickup time is in the past");
}
const remindAt = new Date(pickup.getTime() - leadMinutes * 60_000);
if (remindAt.getTime() < now.getTime() + minGapSeconds * 1000) {
return { kind: "skip", reason: "Reminder would be too late or already over" };
}
return { kind: "create", remindAt, expression: atExpression(remindAt), timeZone: "UTC" };
}
// Deterministic schedule name: same order ID -> same name
export function scheduleName(orderId: string): string {
const direct = `${NAME_PREFIX}${orderId}`;
if (NAME_PATTERN.test(direct)) return direct;
// Invalid characters or too long: hash instead of truncating (avoids collisions)
const hash = createHash("sha256").update(orderId).digest("hex").slice(0, 40);
return `${NAME_PREFIX}h-${hash}`;
}
// ClientToken per order AND version: retry = same token, rebooking = new token
export function clientToken(orderId: string, version: number): string {
const token = createHash("sha256").update(`${orderId}#${version}`).digest("hex");
if (!TOKEN_PATTERN.test(token)) throw new ScheduleError("Invalid ClientToken");
return token;
}
localToInstant only uses Intl.DateTimeFormat, so no extra library is needed. A time from the spring-forward gap is rejected. For the repeated hour in autumn, the function picks the earlier instant. planReminder throws an error if the pickup is already over. If only the reminder is too close, it gets skipped. I did not find a clear description in the docs of what the service does with an at time in the past. Therefore I do not rely on it and check beforehand myself, with a two-minute buffer.
Step 3: Derive names and ClientToken deterministically
The last two functions in schedule.ts are the heart of idempotency. Idempotent means the same call may arrive several times and the result stays the same. The schedule name comes directly from the order ID, for example pickup-B-2026-0042. As a result, you do not need a mapping table. Whoever knows the order ID also knows the schedule and can change or delete it.
According to the API reference, a name may be 1 to 64 characters long and may only contain letters, digits, hyphens, underscores and dots. If the ID contains other characters or gets too long, I use a hash. Truncating would be dangerous, because two long IDs could then end up with the same name. A similar pattern applies to the ClientToken, but without the dot. A SHA-256 hash in hex has exactly 64 characters and therefore fits perfectly.
The token depends on both order and version. If the SDK or your Lambda repeats the same request, the token is identical. On a rebooking the version goes up, and so the token changes. If you leave it out, the service does generate a random token. However, that does not help you when your Lambda starts over with a new invocation after a timeout.
Step 4: Build the complete request
Now comes the most important design decision of the whole example. UpdateSchedule does not behave like a patch. The API reference states that on an update the service takes “all values, including empty values” (UpdateSchedule). Any field you leave out falls back to its default value. That is why I have exactly one function that builds the complete request, whether for creating or for rebooking.
// src/schedule-input.ts
import type { CreateScheduleCommandInput } from "@aws-sdk/client-scheduler";
import { clientToken, scheduleName } from "./schedule.ts";
export interface Environment {
group: string; // schedule group, e.g. "bakery-reminders"
targetArn: string; // ARN of the reminder Lambda
roleArn: string; // execution role the scheduler assumes
dlqArn: string; // SQS standard queue for failed invocations
}
export interface Order {
orderId: string;
version: number; // increases with every rebooking
pickupLocal: string; // "2026-12-24T08:00"
timeZone: string; // "Europe/Berlin"
}
// ALWAYS builds the complete request. UpdateSchedule replaces every field,
// so creating and rebooking share exactly this one function.
export function scheduleRequest(
o: Order,
expression: string,
env: Environment,
): CreateScheduleCommandInput {
return {
Name: scheduleName(o.orderId),
GroupName: env.group,
ScheduleExpression: expression,
ScheduleExpressionTimezone: "UTC",
FlexibleTimeWindow: { Mode: "OFF" },
ActionAfterCompletion: "DELETE",
State: "ENABLED",
Description: `Pickup reminder for order ${o.orderId} (v${o.version})`.slice(0, 512),
ClientToken: clientToken(o.orderId, o.version),
Target: {
Arn: env.targetArn,
RoleArn: env.roleArn,
// Templated Lambda target: Input must be well-formed JSON
Input: JSON.stringify({
orderId: o.orderId,
version: o.version,
pickupLocal: o.pickupLocal,
timeZone: o.timeZone,
}),
RetryPolicy: { MaximumRetryAttempts: 5, MaximumEventAgeInSeconds: 900 },
DeadLetterConfig: { Arn: env.dlqArn },
},
};
}
A few words on the individual fields:
-
FlexibleTimeWindowis mandatory. For reminders I setOFF. WithFLEXIBLEandMaximumWindowInMinutes(1 to 1,440), the service spreads invocations over a window, which suits nightly cleanup jobs. -
ActionAfterCompletion: "DELETE"removes the schedule after the invocation. Without this setting, completed dates pile up. - For a Lambda target,
Inputmust be valid JSON and may be at most 256 KB. I only pass IDs and the version, and the reminder Lambda loads the order fresh. -
RetryPolicyallows 0 to 185 retries and a maximum age of 60 to 86,400 seconds. I use five attempts and 15 minutes, because a pickup reminder that arrives three hours late helps nobody. -
DeadLetterConfigpoints to an SQS standard queue. The scheduler does not support FIFO queues as a DLQ.
Step 5: Write the order Lambda
The order Lambda connects logic and SDK. Creating calls CreateSchedule, rebooking calls UpdateSchedule and cancelling calls DeleteSchedule. The more interesting part is the error cases that are deliberately not errors here.
// src/handlers/order.ts
import {
ConflictException,
CreateScheduleCommand,
DeleteScheduleCommand,
ResourceNotFoundException,
SchedulerClient,
UpdateScheduleCommand,
} from "@aws-sdk/client-scheduler";
import { planReminder, scheduleName, ScheduleError } from "../schedule.ts";
import { scheduleRequest, type Order, type Environment } from "../schedule-input.ts";
const scheduler = new SchedulerClient({});
const LEAD_MINUTES = 60;
const env: Environment = {
group: process.env.SCHEDULE_GROUP!,
targetArn: process.env.REMINDER_ARN!,
roleArn: process.env.SCHEDULER_ROLE_ARN!,
dlqArn: process.env.DLQ_ARN!,
};
type OrderEvent =
| ({ action: "create" | "rebook" } & Order)
| { action: "cancel"; orderId: string };
export async function handler(e: OrderEvent) {
try {
if (e.action === "cancel") return await cancel(e.orderId);
return await plan(e, e.action === "rebook");
} catch (err) {
if (err instanceof ScheduleError) return { status: 400, message: err.message };
throw err;
}
}
async function plan(o: Order, rebooking: boolean) {
const p = planReminder(o.pickupLocal, o.timeZone, LEAD_MINUTES, new Date());
if (p.kind === "skip") {
// Pickup too close: still remove the old reminder (if any)
if (rebooking) await cancel(o.orderId);
return { status: 200, message: p.reason };
}
const request = scheduleRequest(o, p.expression, env);
if (rebooking) {
try {
await scheduler.send(new UpdateScheduleCommand(request));
return { status: 200, message: `rebooked to ${p.expression}` };
} catch (err) {
// Already fired and auto-deleted, or never created -> create it
if (!(err instanceof ResourceNotFoundException)) throw err;
}
}
try {
await scheduler.send(new CreateScheduleCommand(request));
} catch (err) {
// Name already exists (e.g. booking delivered twice) -> treat as done
if (!(err instanceof ConflictException)) throw err;
}
return { status: 201, message: `Reminder ${request.Name} for ${p.expression} UTC` };
}
async function cancel(orderId: string) {
try {
await scheduler.send(
new DeleteScheduleCommand({ Name: scheduleName(orderId), GroupName: env.group }),
);
} catch (err) {
// Does not exist (anymore): goal reached, not an error
if (!(err instanceof ResourceNotFoundException)) throw err;
}
return { status: 200, message: "cancelled" };
}
When rebooking, a ResourceNotFoundException may come back, for example because the old schedule already fired and deleted itself. In that case I simply create it again. According to the API reference, CreateSchedule can also respond with a ConflictException. I treat that as “already exists”, for example when the same booking was delivered twice. For cancellation, ResourceNotFoundException is even the desired outcome, since a schedule that does not exist cannot remind anyone. Also important: pass GroupName on every call. Without it, the request lands in the default group.
Step 6: Write the reminder Lambda
// src/handlers/reminder.ts
interface ReminderInput {
orderId: string;
version: number;
pickupLocal: string;
timeZone: string;
}
// Invoked by the scheduler. Delivery is "at least once":
// before sending, check that the version is current and nothing was sent yet.
export async function handler(e: ReminderInput) {
console.log(
JSON.stringify({
type: "reminder",
orderId: e.orderId,
version: e.version,
text: `Your order is ready for pickup from ${e.pickupLocal.replace("T", " ")} (${e.timeZone}).`,
}),
);
// Here: load the order, compare the version, send email/SMS, mark as "sent"
return { ok: true };
}
The function is thin on purpose. Because the scheduler delivers at least once, a check belongs in front of sending. Is the version in the input still current, and has the message already gone out? I show how to solve this cleanly with DynamoDB in Lambda idempotency with TypeScript.
Step 7: Describe the infrastructure with CDK
// lib/reminder-stack.ts
import { Duration, RemovalPolicy, Stack, Tags, type StackProps } from "aws-cdk-lib";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import * as scheduler from "aws-cdk-lib/aws-scheduler";
import * as sqs from "aws-cdk-lib/aws-sqs";
import type { Construct } from "constructs";
export class ReminderStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
// 1) One schedule group per application, tags live on the group
const group = new scheduler.ScheduleGroup(this, "Group", {
scheduleGroupName: "bakery-reminders",
removalPolicy: RemovalPolicy.DESTROY,
});
Tags.of(group).add("application", "bakery-preorders");
Tags.of(group).add("environment", "dev");
// 2) Dead-letter queue (standard queue, FIFO is not supported)
const dlq = new sqs.Queue(this, "ReminderDlq", {
retentionPeriod: Duration.days(14),
enforceSSL: true,
});
const shared = {
runtime: lambda.Runtime.NODEJS_24_X,
architecture: lambda.Architecture.ARM_64,
memorySize: 256,
timeout: Duration.seconds(10),
bundling: { minify: true, target: "node24" },
};
// 3) Target: the Lambda invoked for the reminder
const reminderFn = new NodejsFunction(this, "ReminderFn", {
...shared,
entry: "src/handlers/reminder.ts",
});
// 4) Execution role for the scheduler, protected against confused deputy
const schedulerRole = new iam.Role(this, "SchedulerRole", {
assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com", {
conditions: {
StringEquals: { "aws:SourceAccount": this.account },
ArnLike: { "aws:SourceArn": group.scheduleGroupArn },
},
}),
});
reminderFn.grantInvoke(schedulerRole);
dlq.grantSendMessages(schedulerRole);
// 5) Order Lambda creates, updates and deletes schedules
const orderFn = new NodejsFunction(this, "OrderFn", {
...shared,
entry: "src/handlers/order.ts",
environment: {
SCHEDULE_GROUP: group.scheduleGroupName,
REMINDER_ARN: reminderFn.functionArn,
SCHEDULER_ROLE_ARN: schedulerRole.roleArn,
DLQ_ARN: dlq.queueArn,
},
});
orderFn.addToRolePolicy(
new iam.PolicyStatement({
actions: [
"scheduler:CreateSchedule",
"scheduler:UpdateSchedule",
"scheduler:DeleteSchedule",
"scheduler:GetSchedule",
],
resources: [
this.formatArn({
service: "scheduler",
resource: "schedule",
resourceName: `${group.scheduleGroupName}/*`,
}),
],
}),
);
// Without PassRole the function may not hand the role to the scheduler
orderFn.addToRolePolicy(
new iam.PolicyStatement({
actions: ["iam:PassRole"],
resources: [schedulerRole.roleArn],
conditions: { StringEquals: { "iam:PassedToService": "scheduler.amazonaws.com" } },
}),
);
}
}
// bin/app.ts
import { App } from "aws-cdk-lib";
import { ReminderStack } from "../lib/reminder-stack.ts";
const app = new App();
new ReminderStack(app, "BakeryReminders", {
env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
For IAM, two roles work together. The order Lambda may only create, update and delete schedules in its own group. In addition, it needs iam:PassRole on the scheduler role, because it hands over that role's ARN in the RoleArn field. The iam:PassedToService condition makes sure the role can only be passed on to the scheduler.
The scheduler role is the execution role that the service assumes during the invocation. Its trust policy checks aws:SourceAccount and aws:SourceArn. This protects against the “confused deputy” problem, where a foreign account tricks the service into using your role. Note that according to the docs, aws:SourceArn must point to a schedule group, not to a single schedule. In terms of permissions, the role may only invoke the reminder Lambda and write to the DLQ.
Step 8: Test, synthesize and deploy
You can test the pure logic without AWS. I use the built-in Node.js test runner for that, and tsx takes care of the TypeScript.
// test/schedule.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import {
atExpression,
clientToken,
localToInstant,
planReminder,
scheduleName,
ScheduleError,
} from "../src/schedule.ts";
import { scheduleRequest } from "../src/schedule-input.ts";
const now = new Date("2026-10-31T10:00:00Z");
test("winter time: 08:00 in Berlin is 07:00 UTC", () => {
const i = localToInstant("2026-12-24T08:00", "Europe/Berlin");
assert.equal(i.toISOString(), "2026-12-24T07:00:00.000Z");
assert.equal(atExpression(i), "at(2026-12-24T07:00:00)");
});
test("summer time: 08:00 in Berlin is 06:00 UTC", () => {
const i = localToInstant("2027-07-01T08:00", "Europe/Berlin");
assert.equal(i.toISOString(), "2027-07-01T06:00:00.000Z");
});
test("spring gap: 02:30 on 2027-03-28 does not exist in Berlin", () => {
assert.throws(() => localToInstant("2027-03-28T02:30", "Europe/Berlin"), ScheduleError);
});
test("repeated hour in autumn: the earlier instant wins", () => {
const i = localToInstant("2026-10-25T02:30", "Europe/Berlin");
assert.equal(i.toISOString(), "2026-10-25T00:30:00.000Z");
});
test("lead time is a real duration across the DST change", () => {
// Pickup 03:30 CEST = 01:30 UTC; 120 minutes earlier = 23:30 UTC (00:30 CET),
// not 01:30 wall-clock time as naive clock arithmetic would suggest
const p = planReminder("2027-03-28T03:30", "Europe/Berlin", 120, now);
assert.equal(p.kind, "create");
if (p.kind === "create") assert.equal(p.expression, "at(2027-03-27T23:30:00)");
});
test("pickup in the past is rejected", () => {
assert.throws(() => planReminder("2026-10-30T08:00", "Europe/Berlin", 60, now), /past/);
});
test("reminder too close: skip instead of error", () => {
// Pickup 11:30 Berlin = 10:30 UTC, reminder 60 min earlier = 09:30 UTC < now
const p = planReminder("2026-10-31T11:30", "Europe/Berlin", 60, now);
assert.equal(p.kind, "skip");
});
test("invalid dates are caught", () => {
assert.throws(() => planReminder("2026-02-30T08:00", "Europe/Berlin", 60, now), ScheduleError);
assert.throws(() => planReminder("12/24/2026 08:00", "Europe/Berlin", 60, now), ScheduleError);
});
test("schedule name is deterministic and follows the rules", () => {
assert.equal(scheduleName("B-2026-0042"), "pickup-B-2026-0042");
assert.equal(scheduleName("B-2026-0042"), scheduleName("B-2026-0042"));
const exotic = scheduleName("Order #42/Miller");
assert.match(exotic, /^[0-9a-zA-Z\-_.]{1,64}$/);
const long = scheduleName("x".repeat(80));
assert.ok(long.length <= 64);
assert.notEqual(scheduleName("a".repeat(70)), scheduleName("a".repeat(71)));
});
test("ClientToken: same on retry, new on rebooking", () => {
assert.equal(clientToken("B-1", 1), clientToken("B-1", 1));
assert.notEqual(clientToken("B-1", 1), clientToken("B-1", 2));
assert.match(clientToken("B-1.5", 1), /^[a-zA-Z0-9\-_]{1,64}$/);
});
test("request contains every field an update would otherwise reset", () => {
const r = scheduleRequest(
{ orderId: "B-7", version: 2, pickupLocal: "2026-12-24T08:00", timeZone: "Europe/Berlin" },
"at(2026-12-24T06:00:00)",
{ group: "g", targetArn: "arn:target", roleArn: "arn:role", dlqArn: "arn:dlq" },
);
assert.equal(r.ActionAfterCompletion, "DELETE");
assert.deepEqual(r.FlexibleTimeWindow, { Mode: "OFF" });
assert.equal(r.Target?.DeadLetterConfig?.Arn, "arn:dlq");
assert.equal(r.Target?.RetryPolicy?.MaximumRetryAttempts, 5);
assert.equal(JSON.parse(r.Target!.Input!).version, 2);
});
npx tsc --noEmit
npm test
npx cdk synth --quiet
On my machine all three commands pass. The test runner ends with this summary, and cdk synth bundles both functions locally with esbuild:
# tests 11
# pass 11
# fail 0
For the deployment you need a bootstrapped account. After that, you can invoke the order Lambda directly. You will find the exact function name in the output of cdk deploy or in the Lambda console.
npx cdk deploy
aws lambda invoke --function-name <OrderFn-name> \
--cli-binary-format raw-in-base64-out \
--payload '{"action":"create","orderId":"B-2026-0042","version":1,"pickupLocal":"2026-12-24T08:00","timeZone":"Europe/Berlin"}' \
response.json
cat response.json
aws scheduler get-schedule --group-name bakery-reminders --name pickup-B-2026-0042
Based on the code, response.json then contains status 201 and the expression at(2026-12-24T06:00:00), which is 07:00 in Berlin. get-schedule shows you the schedule that was created. To clean up, npx cdk destroy is enough. Since the group was created with RemovalPolicy.DESTROY, all pending dates disappear along with it. In production I would therefore keep it at RETAIN.
Which mistakes typically show up?
- Update with half a request: If you only change the expression, you lose the DLQ, the retry settings and
ActionAfterCompletion. As a result, schedules stay around after the invocation. - AccessDenied on CreateSchedule: Usually
iam:PassRoleon the scheduler role is missing, not the scheduler permission itself. - Schedule runs, target is never invoked: Often the trust policy points
aws:SourceArnat a schedule instead of the group. Or the role lackslambda:InvokeFunction. A look into the DLQ usually tells you why. - ValidationException for the name: Characters like
#or/from order numbers are not allowed, and neither are names longer than 64 characters. - Reminder off by an hour: Local time in the at expression, but no time zone set. In that case the scheduler evaluates the expression in UTC.
What does it cost, and which limits apply?
Costs
According to the pricing page, the Free Tier includes 14 million scheduler invocations per month, counted across all Regions except GovCloud. Beyond that, the pricing example for US West lists USD 1.00 per million invocations. A bakery with 10,000 pre-orders a month thus produces 10,000 invocations and stays far inside the free range. Lambda, SQS and CloudWatch Logs are billed separately as usual.
Quotas
| Quota | Default | Adjustable |
|---|---|---|
| Schedules per Region (including completed one-time schedules) | 10,000,000 | Yes |
| CreateSchedule per second | 5,000 in large Regions such as eu-central-1, otherwise 250 | Yes |
| UpdateSchedule and DeleteSchedule per second | 1,000 each in large Regions, otherwise 250 | Yes |
| Invocations per second | 1,000 in large Regions, otherwise 500 | Yes |
| Schedule groups per Region | 500 | Yes |
| Size of Input | 256 KB | No |
| Reads and writes on a single schedule | 10 per second | No |
The invocation limit deserves a second look. When it is exceeded, the scheduler does not drop anything but delays the invocations. So if many pickups fall on 08:00, some reminders arrive a little later. A bakery can live with that. For time-critical targets, however, you plan the quota in advance.
My take
For “one booking, one moment, one invocation”, the EventBridge Scheduler one-time schedule has become my default solution. It replaces polling with a clear responsibility, costs practically nothing at normal volumes and can be described cleanly with CDK. Above all, it fits the serverless mindset, because nothing runs between booking and date.
Still, I see two risks. First, part of your state now lives outside the database. If a DeleteSchedule fails during cancellation, the reminder fires anyway. That is why my reminder Lambda checks the current state instead of trusting the input blindly. Second, I find the behavior of UpdateSchedule treacherous. I would prefer patch semantics, but with a single builder function the problem is easy to keep under control.
I would push back on the common habit of stuffing the complete order into the input. It saves a database read, but in doubt it sends stale data. IDs and the version are perfectly enough. By the way, where you need precision to the second, the scheduler would be the wrong choice, since it works to the minute.
FAQ
How precise is a one-time schedule in EventBridge Scheduler?
An EventBridge Scheduler one-time schedule is accurate to the minute, because all schedule types work with 60-second precision. Without a flexible time window, the service invokes the target within the specified minute.
Can I use at() with local time?
Yes. With ScheduleExpressionTimezone and an IANA name such as Europe/Berlin, the scheduler evaluates the expression in that time zone. If you calculate offsets like “one hour earlier”, UTC is the safer choice.
How do I delete a one-time schedule automatically after it runs?
Set ActionAfterCompletion to DELETE when you create it. The service then removes the schedule after invoking the target.
How many schedules can I create?
By default ten million per Region, and the quota can be raised. Completed one-time schedules count too, as long as they have not been deleted.
How much does EventBridge Scheduler cost?
14 million invocations per month are included in the Free Tier. Beyond that, the pricing page lists USD 1.00 per million invocations in its US West example.
Your next step
Pick a cron job that regularly polls for due entries and check whether one-time schedules could replace it. Start small: one group, one builder function, one test for the DST change. I collect more building blocks like this in the series Serverless patterns in practice.
I am curious: how do you handle dates today that are still hanging around somewhere after a cancellation? Let me know in the comments.
Sources
- Schedule types in EventBridge Scheduler (AWS docs)
- API reference: CreateSchedule
- UpdateSchedule in the API reference
- DeleteSchedule (API reference)
- RetryPolicy and FlexibleTimeWindow
- Target object (API reference)
- Quotas for Amazon EventBridge Scheduler
- Setting up Amazon EventBridge Scheduler
- Confused deputy prevention (AWS docs)
- Identity-based policies for EventBridge Scheduler
- Managing a schedule group
- Configuring a schedule's dead-letter queue
- Configuring flexible time windows
- What is Amazon EventBridge Scheduler?
- Amazon EventBridge pricing
- Using time to live (TTL) in DynamoDB
- Step Functions: Wait workflow state and service quotas
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)