Cron Jobs in Serverless Environments: Cloudflare Cron Triggers, AWS EventBridge, and Inngest Compared
Serverless platforms don't have a persistent process to run crontab. This guide compares Cloudflare Cron Triggers, AWS EventBridge + Lambda, and Inngest on scheduling semantics, failure handling, observability, and cost, with TypeScript implementations for each.
Scheduled work is one of those things that looks simple until you actually have to run it reliably at scale. On a traditional server, you add a line to crontab, point it at a script, and move on. In a serverless environment, there is no persistent process to wake up at 3 AM and run your billing reconciliation job.
Every serverless platform solves this differently, and the differences matter when your scheduled job touches payments, sends emails, or syncs data to third-party APIs. Missing a run, running twice, or failing silently all have real consequences.
This article covers three concrete approaches:
- Cloudflare Cron Triggers: schedule expressions attached to Workers, with global execution close to zero configuration.
- AWS EventBridge Scheduled Rules: cron and rate expressions that invoke Lambda functions, with full AWS integration.
- Inngest: a managed orchestration layer that adds durable step functions, fan-out, and first-class observability on top of any HTTP runtime.
The comparison covers scheduling semantics, failure handling, observability, cost model, and a decision framework for choosing between them.
Why Traditional Cron Doesn’t Translate
On a long-running server, cron works because there is always a process awake to check the schedule. In serverless, functions are instantiated on demand and torn down after execution. No persistent process means no crontab.
Each platform has to solve: where does the clock live, how is the function invoked, what happens on failure, and how do I know if something went wrong?
The answers diverge significantly.
Cloudflare Cron Triggers
Cloudflare Workers supports scheduled invocations through Cron Triggers. You define a schedule in wrangler.toml, export a scheduled handler from your Worker, and Cloudflare’s infrastructure handles the rest. No external scheduler, no separate queue, no polling loop.
Configuration
# wrangler.toml
name = "billing-reconciler"
main = "src/index.ts"
compatibility_date = "2024-11-01"
[triggers]
crons = ["0 3 * * *"] # Daily at 03:00 UTC
Handler
export interface Env {
DB: D1Database;
STRIPE_SECRET_KEY: string;
}
export default {
async scheduled(
event: ScheduledEvent,
env: Env,
ctx: ExecutionContext
): Promise<void> {
// event.cron is the cron expression that triggered this run
// event.scheduledTime is the Unix timestamp of the scheduled invocation
console.log(`Triggered by: ${event.cron} at ${new Date(event.scheduledTime)}`);
ctx.waitUntil(runReconciliation(env));
},
};
async function runReconciliation(env: Env): Promise<void> {
const { results } = await env.DB.prepare(
"SELECT id, stripe_customer_id FROM accounts WHERE reconciled_at < datetime('now', '-1 day')"
).all<{ id: string; stripe_customer_id: string }>();
for (const account of results) {
await reconcileAccount(account, env.STRIPE_SECRET_KEY);
}
}
ctx.waitUntil() is important here. Without it, the Worker may be torn down before your async work finishes. waitUntil extends the Worker’s lifetime until the passed promise settles.
Failure Handling
Cloudflare does not retry failed scheduled handlers automatically. If runReconciliation throws, the run is marked as failed and the next run happens at the next scheduled time. You own the retry logic inside the handler, or you accept that failures mean a missed run.
For jobs that must not miss: handle errors inside the handler, write failures to a durable store (D1, KV, or an external queue), and implement your own catch-up logic.
async function runReconciliation(env: Env): Promise<void> {
try {
// ... reconciliation logic
} catch (err) {
// Write failure record to D1 for catch-up
await env.DB.prepare(
"INSERT INTO cron_failures (job, error, failed_at) VALUES (?, ?, datetime('now'))"
).bind("billing-reconciler", String(err)).run();
// Re-throw so Cloudflare marks the invocation as failed
throw err;
}
}
Observability
Workers Logs streams execution logs to Cloudflare’s dashboard. You can also forward to Logpush-compatible destinations (Datadog, Splunk, S3). The scheduledTime field in the event lets you detect clock drift or missed runs by comparing it to Date.now().
For alerting on failures, you need external monitoring. Cloudflare does not send notifications on cron failure by default. A simple pattern is a heartbeat: write a timestamp to KV on each successful run and alert externally if that timestamp is stale.
AWS EventBridge + Lambda
AWS EventBridge Scheduler (formerly CloudWatch Events) invokes Lambda functions on a schedule. You define a rule with either a cron expression or a rate expression, target a Lambda function, and EventBridge handles invocation.
This is the most feature-complete option in the comparison, partly because AWS has had years to layer on retry policies, dead-letter queues, and CloudWatch integrations. It is also the most operationally complex.
Infrastructure (AWS CDK)
import * as cdk from "aws-cdk-lib";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as sqs from "aws-cdk-lib/aws-sqs";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
export class BillingReconcilerStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const dlq = new sqs.Queue(this, "ReconcilerDLQ", {
retentionPeriod: cdk.Duration.days(14),
});
const reconcilerFn = new NodejsFunction(this, "BillingReconciler", {
entry: "src/handlers/reconciler.ts",
handler: "handler",
timeout: cdk.Duration.minutes(5),
retryAttempts: 2,
deadLetterQueue: dlq,
environment: {
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY!,
},
});
const rule = new events.Rule(this, "DailyReconcilerRule", {
schedule: events.Schedule.cron({
minute: "0",
hour: "3",
day: "*",
month: "*",
year: "*",
}),
});
rule.addTarget(new targets.LambdaFunction(reconcilerFn, {
retryAttempts: 2,
maxEventAge: cdk.Duration.hours(2),
}));
}
}
Lambda Handler
import { ScheduledEvent, Context } from "aws-lambda";
export async function handler(
event: ScheduledEvent,
context: Context
): Promise<void> {
console.log("Triggered at:", event.time);
console.log("Remaining time (ms):", context.getRemainingTimeInMillis());
// EventBridge passes the rule ARN and scheduled time in the event
const scheduledAt = new Date(event.time);
await runReconciliation(scheduledAt);
}
async function runReconciliation(scheduledAt: Date): Promise<void> {
const accounts = await fetchAccountsNeedingReconciliation();
for (const account of accounts) {
await reconcileAccount(account);
}
// Write a success marker for monitoring
await writeHeartbeat("billing-reconciler", scheduledAt);
}
Failure Handling
EventBridge target configuration gives you two levers. retryAttempts controls how many times EventBridge retries a failed invocation (up to 185 attempts, with exponential backoff). maxEventAge discards events older than the specified duration even if retries remain, preventing stale reconciliations from running hours later.
When retries are exhausted, the event goes to the dead-letter queue if you configured one. From there, you can inspect failed invocations, replay them manually, or trigger an alert.
This is meaningfully better than Cloudflare’s no-retry behavior for jobs where at-least-once execution matters.
Observability
CloudWatch Metrics gives you Invocations, Errors, Duration, and Throttles out of the box. CloudWatch Alarms on Errors is the standard pattern for alerting on cron failures. Structured logs from Lambda go to CloudWatch Logs, and you can forward to your preferred log aggregator via a subscription filter.
One gap: CloudWatch does not have a built-in “expected invocation did not happen” alarm. You still need external monitoring (Cronitor, Dead Man’s Snitch, or a custom synthetic) to catch the case where EventBridge itself fails to invoke your function.
Inngest
Inngest takes a different approach. Rather than a platform-level scheduler, Inngest is a managed orchestration service that runs alongside your application. You define scheduled functions in TypeScript, Inngest invokes your HTTP server on schedule, and your function runs as a series of durable steps.
The key difference: Inngest steps are individually retried, not the whole function. If step 3 of 5 fails, Inngest retries from step 3, not from step 1.
Setup
// src/inngest/client.ts
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "billing-service" });
// src/inngest/reconciler.ts
import { inngest } from "./client";
export const dailyReconciler = inngest.createFunction(
{
id: "daily-billing-reconciler",
name: "Daily Billing Reconciler",
},
// Cron expression: every day at 03:00 UTC
{ cron: "0 3 * * *" },
async ({ step, logger }) => {
// Each step.run() call is a durable checkpoint.
// If this step fails, Inngest retries from here.
const accounts = await step.run("fetch-accounts", async () => {
return fetchAccountsNeedingReconciliation();
});
logger.info(`Reconciling ${accounts.length} accounts`);
// Fan out: each account is reconciled independently.
// A failure in one does not block the others.
await step.run("reconcile-accounts", async () => {
const results = await Promise.allSettled(
accounts.map((account) => reconcileAccount(account))
);
const failures = results.filter((r) => r.status === "rejected");
if (failures.length > 0) {
// Log failures without throwing, so the step succeeds
logger.warn(`${failures.length} accounts failed reconciliation`);
await writeFailures(failures);
}
});
return { reconciled: accounts.length };
}
);
// src/app/api/inngest/route.ts (Next.js App Router)
import { serve } from "inngest/next";
import { inngest } from "@/inngest/client";
import { dailyReconciler } from "@/inngest/reconciler";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [dailyReconciler],
});
Failure Handling
Inngest retries each step independently, with configurable retry counts and backoff. You can set per-function retry configuration:
export const dailyReconciler = inngest.createFunction(
{
id: "daily-billing-reconciler",
name: "Daily Billing Reconciler",
retries: 4,
// Throttle: no more than 1 concurrent execution
concurrency: { limit: 1 },
},
{ cron: "0 3 * * *" },
async ({ step, attempt, logger }) => {
if (attempt > 0) {
logger.warn(`Retry attempt ${attempt}`);
}
// ... function body
}
);
If all retries are exhausted, Inngest marks the function run as failed and surfaces it in the dashboard. You can configure webhook notifications on failure and replay failed runs from the UI.
Observability
Inngest’s dashboard shows every function run, its status, each step’s execution history, and the exact error if it failed. For long-running scheduled jobs, this level of step-by-step traceability is significantly better than reading CloudWatch Logs or grepping Workers output.
The tradeoff: Inngest is an external dependency. Your scheduled function only runs when Inngest can reach your HTTP server. Cold start on a serverless HTTP endpoint (Next.js on Vercel, for instance) can add latency before the step actually executes.
Tradeoffs Comparison
| Dimension | Cloudflare Cron Triggers | AWS EventBridge + Lambda | Inngest |
|---|---|---|---|
| Scheduling precision | Minute-level, global | Minute-level, single region | Minute-level, Inngest-managed |
| Retry on failure | None (manual) | Up to 185 retries (EventBridge level) | Per-step retries, configurable |
| Dead-letter queue | No (build your own) | Yes (SQS DLQ) | Failed runs dashboard + webhooks |
| Step-level durability | No | No | Yes |
| Observability (built-in) | Workers Logs, Logpush | CloudWatch Metrics + Logs | Full run history in dashboard |
| Miss detection | No | No | Partial (dashboard) |
| Lock-in | Cloudflare Workers | AWS ecosystem | Inngest SDK + HTTP server |
| Cold start | None (Workers are warm) | Up to ~1s (depending on runtime) | Depends on host runtime |
| Configuration surface | Minimal (wrangler.toml) | High (CDK/IaC, IAM, DLQ) | Moderate (SDK config) |
| Cost model | Included in Workers plan | EventBridge rule + Lambda invocation | Free tier, then usage-based |
| Maximum execution time | 30s (default) / no limit on paid | 15 minutes | No hard limit (step-based) |
Production Considerations
Idempotency is non-negotiable. All three platforms can invoke your function more than once: Cloudflare if there is a transient infra issue, EventBridge under at-least-once delivery semantics, and Inngest if a step is retried. Design your handlers so running twice produces the same result as running once.
A simple pattern: write a cron_runs record with a unique key per schedule time before doing any work, and short-circuit if the record already exists.
async function ensureIdempotent(db: D1Database, runKey: string): Promise<boolean> {
const result = await db.prepare(
"INSERT OR IGNORE INTO cron_runs (run_key, started_at) VALUES (?, datetime('now'))"
).bind(runKey).run();
// meta.changes === 0 means the row already existed: skip this run
return result.meta.changes > 0;
}
Execution time limits are real constraints. Cloudflare Workers have a 30-second CPU time limit on the free plan (30ms) and a more generous cap on paid plans, but not unlimited. AWS Lambda caps at 15 minutes. Inngest has no per-function time limit because each step is a discrete invocation, but individual steps still run in your host runtime with its own limits.
If your scheduled job processes thousands of records, you need a different pattern than a single synchronous loop. In Cloudflare, fan out to a Queue. In Lambda, use Step Functions or chunk the work into smaller Lambda invocations. In Inngest, step.run() per batch is the natural model.
Timezone handling. Cron expressions are UTC by default on all three platforms. If your business logic depends on a specific timezone (for example, sending a daily digest at 9 AM local time), you need to manage the UTC offset yourself. EventBridge Scheduler supports timezone-aware schedules natively since 2022. Cloudflare and Inngest do not: convert to UTC before writing the cron expression.
Clock skew and delayed invocations. Platforms guarantee eventual invocation, not exact-time invocation. A job scheduled for 03:00 UTC might run at 03:00:04 or 03:00:45. If your job does time-windowed queries (for example, “select events from the last hour”), use scheduledTime from the event, not Date.now(), as the reference point.
Decision Framework
Start here:
Are you already on Cloudflare Workers? Use Cron Triggers. Zero additional dependencies, zero additional cost, and the integration is a single field in wrangler.toml. The lack of built-in retries is a gap, but it is manageable with the failure-record pattern shown above.
Are you on AWS and need a reliable at-least-once guarantee with native DLQ support? Use EventBridge + Lambda. The operational overhead is real, but the retry and dead-letter semantics are production-grade, and CloudWatch Alarms give you alerting with minimal work.
Do you have complex multi-step scheduled jobs where partial failures matter? Use Inngest. Billing reconciliation that fans out to hundreds of accounts, nightly ETL pipelines, or any job where “retry the whole thing” wastes too many resources and masks the actual failure: Inngest’s step model is the right fit. The observability dashboard alone pays for itself in debugging time.
Are you on Vercel, Railway, Render, or another managed platform without a native scheduler? Inngest is the practical choice. It works over HTTP and does not require any platform-specific primitives.
Scheduled work is the part of the system that fails quietly. A job that runs at 3 AM and silently throws an exception will not wake anyone up unless you built the monitoring for it. Whichever platform you choose, the critical investment is the same: idempotent handlers, observable failures, and an external heartbeat monitor that detects when the expected invocation never happened. The platform only controls the first part of that story.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.