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 StartDate and EndDate. Only the at expression defines the moment.
  • A completed one-time schedule sticks around and keeps counting against your quota. That is why ActionAfterCompletion exists: with the value DELETE, 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.

Matrix comparing EventBridge Scheduler, DynamoDB TTL and Step Functions Wait by precision, limits and cancellation

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 synth you 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, Sche...
Source:https://dev.to/sascha-ms/eventbridge-scheduler-one-time-schedule-for-every-booking-123h