Skip to content

EventBridge & Scheduler

EventBridge is AWS’s event router — producers publish events onto a bus, rules match them by pattern, and targets receive them. EventBridge Scheduler is its time-based sibling: fire an invocation on a rate(...), cron(...), or one-time at(...) expression. In Alchemy the surface is:

  • EventBridge.EventBus and EventBridge.Rule — the raw resources.
  • EventBridge.PutEvents — a producer binding that grants events:PutEvents on the bus you publish to.
  • EventBridge.consumeBusEvents — subscribe a Lambda to a pattern as a typed Stream, creating the rule and invoke permission for you.
  • EventBridge.events(bus, pattern).toLambda/.toQueue/.toEcsTask — route matching events to a target with no handler code.
  • Scheduler.every/cron/at — chainable schedule builders that synthesize the target’s IAM role.
  • ECS.every — run a Fargate task on a schedule.

An event bus is a one-line resource. Declare it in a shared module so producers and consumers can reference the same handle:

src/bus.ts
import * as AWS from "alchemy/AWS";
export const Events = AWS.EventBridge.EventBus("Events", {});

No name required — Alchemy generates one from the app, stage, and logical ID. The name default is reserved: to target the account’s default bus (where AWS service events land), simply omit the bus argument in the APIs below.

EventBridge.PutEvents(bus) returns a callable Effect and quietly attaches an events:PutEvents policy statement scoped to the bus ARN (or the default bus, if you pass nothing):

src/api.ts
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { Events } from "./bus.ts";
export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url, url: true },
Effect.gen(function* () {
const bus = yield* Events;
const putEvents = yield* AWS.EventBridge.PutEvents(bus);
return {
fetch: Effect.gen(function* () {
yield* putEvents({
Entries: [
{
Source: "app.orders",
DetailType: "OrderPlaced",
Detail: JSON.stringify({ orderId: "o-1" }),
},
],
}).pipe(Effect.orDie);
return HttpServerResponse.empty({ status: 202 });
}),
};
}).pipe(Effect.provide(AWS.EventBridge.PutEventsHttp)),
) {}

Entries carry a Source, a DetailType, and a JSON-encoded Detail — the three fields rules match on. The EventBusName is filled in from the bus you bound, so entries never repeat it. AWS.EventBridge.PutEventsHttp is the runtime layer that implements the binding over the EventBridge API.

consumeBusEvents is the consumer-side mirror of SQS.consumeQueueMessages: pass the bus, an event pattern, and a handler that receives matching events as a typed Stream:

src/worker.ts
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import * as Stream from "effect/Stream";
import { Events } from "./bus.ts";
export default class Worker extends AWS.Lambda.Function<Worker>()(
"OrdersWorker",
{ main: import.meta.url },
Effect.gen(function* () {
const bus = yield* Events;
yield* AWS.EventBridge.consumeBusEvents(
bus,
{ source: ["app.orders"], "detail-type": ["OrderPlaced"] },
(events) =>
Stream.runForEach(events, (event) =>
Effect.log(`order placed: ${JSON.stringify(event.detail)}`),
),
);
return {};
}).pipe(Effect.provide(AWS.Lambda.EventSource)),
) {}

That single call does the infrastructure work at deploy time — an EventBridge Rule with the pattern targeting this function, plus the Lambda permission that lets events.amazonaws.com invoke it — and at runtime filters incoming invocations against the same pattern before feeding them into your stream. Omit the bus argument to subscribe to the default bus, which is where AWS service events (S3, EC2, ECS state changes, …) arrive.

AWS.Lambda.EventSource is the Lambda runtime implementation of the EventBridge event source — provide it at the bottom of the function like any other event source layer.

Events are EventRecord<Detail> with Detail defaulting to unknown. Narrow the stream when you know the payload shape:

type OrderPlaced = { orderId: string };
yield* AWS.EventBridge.consumeBusEvents(
bus,
{ source: ["app.orders"], "detail-type": ["OrderPlaced"] },
(events) =>
Stream.runForEach(
events as Stream.Stream<AWS.EventBridge.EventRecord<OrderPlaced>>,
(event) => Effect.log(`order: ${event.detail.orderId}`),
),
);

Not every consumer is code you own. EventBridge.events(bus, pattern) builds a route — chain .toLambda, .toQueue, or .toEcsTask to deliver matching events to a target resource, permissions included:

alchemy.run.ts
yield* AWS.EventBridge.events(bus, { source: ["app.orders"] })
.toQueue(auditQueue);

Each target wires its own access: .toQueue attaches a queue policy allowing events.amazonaws.com to sqs:SendMessage (conditioned on the rule’s ARN), .toLambda adds the invoke permission, and .toEcsTask creates an IAM role with ecs:RunTask and iam:PassRole for the task’s roles. Pass a string as the first argument to name the backing rule deterministically instead of deriving it from the pattern hash.

The helpers above all bottom out in the Rule resource. Reach for it directly when you need full control — input transformers, dead-letter queues, retry policies, or up to five targets per rule:

const rule = yield* AWS.EventBridge.Rule("ObjectCreated", {
eventPattern: {
source: ["aws.s3"],
"detail-type": ["Object Created"],
},
targets: [{ Id: "Jobs", Arn: queue.queueArn }],
});

A rule needs an eventPattern or a scheduleExpression (rules also accept rate(...)/cron(...) expressions, though Scheduler below is the better tool for time-based work). Omitting eventBusName puts the rule on the default bus. Unlike the events(...) helpers, a raw Rule does not grant the target-side permissions — supply a target RoleArn or resource policy yourself.

AWS.Scheduler exposes chainable builders. The simplest form invokes a Lambda on a fixed rate:

alchemy.run.ts
const api = yield* Api;
yield* AWS.Scheduler.every("5 minutes").toLambda(api);

every accepts a plain-English duration (normalized to rate(5 minutes)) or a full rate(...)/cron(...) expression as-is. The builder synthesizes the piece Scheduler always demands — an execution role assumable by scheduler.amazonaws.com with lambda:InvokeFunction on exactly that function — then creates the Scheduler.Schedule resource.

Cron schedules take an options object with a timezone, and .named pins the schedule’s name:

yield* AWS.Scheduler
.cron("cron(0 3 * * ? *)", { timezone: "America/New_York" })
.named("nightly-report")
.toLambda(api, { input: { job: "nightly-report" } });

input is delivered as the invocation payload (objects are JSON-stringified for you); retryPolicy and deadLetterConfig are also accepted per target.

One-time schedules use at(date), and .toQueue sends a payload to SQS instead of invoking a function:

yield* AWS.Scheduler
.at(new Date("2026-08-01T09:00:00Z"))
.toQueue(queue, { type: "launch.reminder" });

Builder options cover the rest of the Scheduler feature set — group (a Scheduler.ScheduleGroup), startDate/endDate, flexibleTimeWindow, state, kmsKeyArn, and actionAfterCompletion for one-time schedules.

The builders materialize Scheduler.Schedule under the hood. Use it directly when you bring your own role:

const schedule = yield* AWS.Scheduler.Schedule("Poll", {
scheduleExpression: "rate(1 hour)",
flexibleTimeWindow: { Mode: "OFF" },
target: {
Arn: queue.queueArn,
RoleArn: role.roleArn,
},
});

The RoleArn must trust scheduler.amazonaws.com. Alchemy retries the creation while the freshly-created IAM role propagates, so a role declared in the same deploy just works.

For jobs too heavy for Lambda, AWS.ECS.every runs a Fargate task on a schedule — cron-as-a-container:

yield* AWS.ECS.every("HourlyJob", "1 hour", {
cluster,
task: jobTask,
subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId],
securityGroups: [jobSecurityGroup.groupId],
});

cluster and task are the outputs of AWS.ECS.Cluster and AWS.ECS.Task (see ECS for standing those up). Under the hood this is Scheduler.every(...).named("HourlyJob").toEcsTask(...): a schedule plus an execution role with ecs:RunTask on the task definition and iam:PassRole for its task and execution roles. Plain durations normalize to rate(...); full cron(...) expressions pass through unchanged:

yield* AWS.ECS.every("NightlyJob", "cron(0 3 * * ? *)", {
cluster,
task: nightlyTask,
subnets: [privateSubnet1.subnetId, privateSubnet2.subnetId],
securityGroups: [jobSecurityGroup.groupId],
taskCount: 3,
input: JSON.stringify({ source: "scheduler" }),
});

Tasks launch as Fargate with no public IP by default (assignPublicIp: true to change that), taskCount copies per tick, and an optional static input forwarded to the task.

  • Lambda — the runtime both the consumer and scheduled targets above deploy to.
  • SNS — pub/sub fan-out when you want subscriptions rather than pattern-matching rules.
  • ECS — the cluster, task, and networking that ECS.every schedules onto.
  • EventBus, Rule, and Schedule references — every prop and attribute.