Scheduled Functions: Cron Jobs with Inngest
Every application eventually needs work that runs on a schedule. Daily reports. Weekly digests. Hourly data syncs. Nightly database cleanups. Monthly invoice generation.
The traditional way to do this — crontab on a Linux server, Kubernetes CronJob, AWS ECS Scheduled Task, or a Vercel/Netlify cron endpoint — works, but carries a persistent set of frustrations: they're platform-specific, hard to test locally, don't retry on failure, and offer no observability into whether they actually ran.
With Inngest, scheduled functions are just a trigger choice. Instead of { event: "..." }, you write { cron: "..." }. Everything else — steps, retries, the Dev Server, the dashboard run history — works exactly as it does for event-triggered functions.
Let's build them.
Quick Reference
Trigger syntax: { cron: "0 9 * * MON" } — standard 5-field cron expression
Timezone syntax: { cron: "TZ=America/New_York 0 9 * * MON" } — prefix with TZ=IANA_timezone
Testing locally: Use the Dev Server's Invoke button in the Functions tab — no need to wait for the schedule
Combining with fan-out: Load a list in the cron function, step.sendEvent() an event per item, process each item in a separate function
DST warning: Avoid scheduling at 2:00 AM local time — DST transitions can cause functions to skip or double-run
What You Need to Know First
Required reading (in order):
- Event-Driven Architecture: Why Your App Needs It
- Your First Inngest Function
- Steps: Breaking Work into Durable Units
- Fan-Out: Triggering Multiple Tasks from One Event — essential if you'll process large lists
You should understand:
- Basic cron syntax (we'll cover it, but familiarity helps)
- How
step.sendEvent()fans out work to parallel functions
What We'll Cover in This Article
By the end of this guide, you'll understand:
- How to write a scheduled function with a cron trigger
- Cron expression syntax — all five fields explained
- How to set a timezone for scheduled functions
- The DST (Daylight Saving Time) gotcha and how to avoid it
- How to test cron functions locally without waiting for the schedule
- The cron + fan-out pattern for large-scale scheduled batch processing
- Common production use cases with working code
What We'll Explain Along the Way
- Cron expression syntax (minute / hour / day / month / weekday)
- IANA timezone names (what they are and where to find them)
- Why
step.run()matters especially in cron functions (which have no natural retry trigger)
Part 1: Your First Scheduled Function
Converting from an event-triggered function to a scheduled function is a one-line change. Instead of { event: "..." } as the trigger, you use { cron: "..." }.
// Event-triggered function — runs when an event fires
export const sendWelcomeEmail = inngest.createFunction(
{ id: "send-welcome-email" },
{ event: "user/account.created" }, // ← event trigger
async ({ event, step }) => {
/* ... */
},
);
// Scheduled function — runs on a cron schedule
export const sendWeeklyReport = inngest.createFunction(
{ id: "send-weekly-report" },
{ cron: "0 9 * * MON" }, // ← cron trigger: every Monday at 9:00 AM UTC
async ({ step }) => {
// ← no `event` — cron functions don't receive one
await step.run("generate-and-send-report", async () => {
const report = await reportService.generateWeekly();
await emailService.sendToTeam(report);
});
},
);
Notice two differences from event-triggered functions:
- The trigger is
{ cron: "0 9 * * MON" }— a standard cron expression - The handler doesn't receive
event— scheduled functions aren't triggered by an event, so there's no event payload to destructure
Everything else is identical. You can use step.run(), step.sleep(), step.sendEvent(), step.waitForEvent(), retries, onFailure — the full step API. The cron trigger just controls when the function starts.
Part 2: Cron Syntax — Five Fields Explained
Inngest uses standard five-field cron expressions. If you've used cron before, you already know this. If not, it's simpler than it looks.
┌─────────── minute (0–59)
│ ┌──────── hour (0–23)
│ │ ┌───── day of month (1–31)
│ │ │ ┌── month (1–12)
│ │ │ │ ┌─ day of week (0–7, where 0 and 7 both mean Sunday)
│ │ │ │ │
* * * * *
Each field can be:
- A specific value:
5(the 5th minute, or 5 AM, or the 5th day) - A wildcard
*: any value - A range:
1-5(Monday through Friday) - A step:
*/15(every 15 units) - A list:
1,3,5(Monday, Wednesday, Friday)
A reference table of common schedules
| Expression | Meaning |
|---|---|
* * * * * | Every minute |
*/5 * * * * | Every 5 minutes |
*/15 * * * * | Every 15 minutes |
0 * * * * | Every hour, on the hour |
0 */6 * * * | Every 6 hours (midnight, 6 AM, noon, 6 PM) |
0 9 * * * | Every day at 9:00 AM |
0 0 * * * | Every day at midnight |
0 9 * * MON | Every Monday at 9:00 AM |
0 9 * * 1-5 | Every weekday (Mon–Fri) at 9:00 AM |
0 9 * * 1,3,5 | Monday, Wednesday, Friday at 9:00 AM |
0 0 1 * * | First day of every month at midnight |
0 0 1 1 * | January 1st at midnight (once a year) |
30 8 * * MON | Every Monday at 8:30 AM |
Day-of-week values:
| Number | Name | Short |
|---|---|---|
| 0 or 7 | Sunday | SUN |
| 1 | Monday | MON |
| 2 | Tuesday | TUE |
| 3 | Wednesday | WED |
| 4 | Thursday | THU |
| 5 | Friday | FRI |
| 6 | Saturday | SAT |
Inngest supports both numeric values (0–7) and three-letter abbreviations (SUN, MON, TUE, WED, THU, FRI, SAT).
Validating your expressions
Before deploying, validate cron expressions at crontab.guru — it shows you exactly when your expression will next run in plain English. This catches off-by-one errors before they become production issues.
Part 3: Timezones
By default, Inngest cron expressions run in UTC. For most system-level jobs (data syncs, cleanup, backups), UTC is the right choice — it's consistent and unambiguous.
But for user-facing jobs — "send the weekly email at 9 AM on Monday when users are starting their week" — you often want a specific timezone. A 9 AM UTC send reaches Sydney users at 7 PM, London users at 9 AM (summer) or 10 AM (winter), and New York users at 4 AM or 5 AM. That's rarely what you want.
Specify a timezone by prefixing the cron expression with TZ=IANA_timezone_name:
// Every Monday at 9:00 AM — but in which timezone?
// UTC (default — no prefix)
{
cron: "0 9 * * MON";
}
// New York (Eastern Time — handles EST/EDT automatically)
{
cron: "TZ=America/New_York 0 9 * * MON";
}
// London (GMT/BST)
{
cron: "TZ=Europe/London 0 9 * * MON";
}
// Paris (CET/CEST)
{
cron: "TZ=Europe/Paris 0 9 * * MON";
}
// Tokyo (JST — no DST, always UTC+9)
{
cron: "TZ=Asia/Tokyo 0 9 * * MON";
}
// Sydney (AEST/AEDT)
{
cron: "TZ=Australia/Sydney 0 9 * * MON";
}
// Dhaka (BST — UTC+6, no DST)
{
cron: "TZ=Asia/Dhaka 0 9 * * MON";
}
IANA timezone names follow the format Region/City — these are the same identifiers used in JavaScript's Intl.DateTimeFormat. A complete list is available at the IANA Time Zone Database.
The DST warning
As Inngest's documentation explicitly notes: "Schedules near DST transition times can behave unexpectedly in local timezones. Depending on the timezone and the exact schedule, a cron may run zero, one, or two times in a day when clocks change."
This matters if you schedule around 2:00 AM in timezones that observe Daylight Saving Time. On the night clocks "spring forward," 2:00 AM doesn't exist — the hour jumps from 1:59 AM to 3:00 AM. A cron scheduled for 0 2 * * * in America/New_York would simply not run that night. On the night clocks "fall back," 2:00 AM happens twice — the cron might run twice.
How to avoid it:
- Use UTC for system jobs — UTC never observes DST, so
0 2 * * *in UTC is always exactly 2:00 AM UTC - Avoid 2:00 AM schedules in DST-observing timezones — schedule at 3:00 AM, 4:00 AM, or any time outside the DST transition window
- Use fixed-offset timezones for countries that don't observe DST —
Asia/Tokyo(JST, always UTC+9),Asia/Dhaka(BST, always UTC+6),Asia/Kolkata(IST, always UTC+5:30)
// ❌ Risky — 2 AM in New York skips or doubles during DST transitions
{
cron: "TZ=America/New_York 0 2 * * *";
}
// ✅ Safe — UTC is never affected by DST
{
cron: "0 7 * * *";
} // 7 AM UTC = 3 AM Eastern (winter) or 2 AM Eastern (summer)
// ✅ Safe — choose a time away from the DST window
{
cron: "TZ=America/New_York 0 9 * * *";
} // 9 AM New York, no DST risk
Part 4: Testing Cron Functions Locally
Here's the problem: if your cron runs every Monday at 9 AM, you can't wait until Monday to test it. And you definitely can't change the schedule to "* * * * *" (every minute) just to get it to fire — that pollutes your production logs and forgets to test the actual schedule expression.
Inngest solves this cleanly. In the Dev Server, every registered function — including cron functions — has an Invoke button in the Functions tab. Clicking it runs the function immediately, exactly as it would run on schedule, and shows you the full execution trace in the Runs tab.
Dev Server workflow for testing crons:
1. Start your app: INNGEST_DEV=1 npm run dev
2. Start the Dev Server: npx inngest-cli@latest dev
3. Open http://localhost:8288/functions
4. Find your cron function (e.g., "send-weekly-report")
5. Click "Invoke"
6. Watch the run execute in the Runs tab
7. Inspect each step's output, timing, and any errors
This gives you full production-parity testing without waiting for the schedule, without changing the cron expression, and without any special test setup.
The Dev Server also shows you the next scheduled run time for each cron function — useful for verifying your expression does what you think it does before deploying.
Part 5: Writing Good Scheduled Functions
Wrap everything in step.run()
Scheduled functions have no natural "re-trigger" mechanism. If a cron function fails without retries, the work is simply lost until the next scheduled run. Wrapping work in step.run() gives each piece of work its own retry budget:
// ❌ No steps — if this crashes, it's gone until next week
export const weeklyReport = inngest.createFunction(
{ id: "weekly-report" },
{ cron: "0 9 * * MON" },
async () => {
const data = await db.query("SELECT ..."); // fails → whole function fails
const report = await reportService.generate(data);
await emailService.send(report);
},
);
// ✅ With steps — each phase retries independently
export const weeklyReport = inngest.createFunction(
{ id: "weekly-report" },
{ cron: "0 9 * * MON" },
async ({ step }) => {
const data = await step.run("fetch-report-data", async () => {
return await db.query("SELECT ...");
});
const report = await step.run("generate-report", async () => {
return await reportService.generate(data);
});
await step.run("send-report-email", async () => {
await emailService.sendToTeam(report);
});
},
);
Now if the email service is briefly down when Monday's report tries to send, only the email step retries — the data is already fetched and the report already generated.
Keep cron functions short — fan out the heavy work
A cron function that processes 10,000 users in a loop is fragile. If it crashes at user 7,832, you either restart from the beginning or lose the rest. It's also slow — everything runs sequentially.
The better pattern: the cron function does only the orchestration (load the list, fan out), and hands the actual work to individual worker functions that each handle one item. This is the cron + fan-out pattern from Article 7 applied to scheduled triggers.
// ── The cron function: orchestrate only, delegate the work ────────────────
export const weeklyDigestOrchestrator = inngest.createFunction(
{ id: "weekly-digest-orchestrator" },
{ cron: "TZ=America/New_York 0 9 * * FRI" }, // Every Friday at 9 AM New York
async ({ step }) => {
const users = await step.run("load-digest-recipients", async () => {
return await db.users
.where({ weeklyDigestEnabled: true, status: "active" })
.select(["id", "email", "name", "timezone"])
.all();
});
// Fan out — one event per user spawns one independent function run
await step.sendEvent(
"fan-out-weekly-digests",
users.map((user) => ({
name: "digest/weekly.requested",
data: {
userId: user.id,
email: user.email,
name: user.name,
timezone: user.timezone,
weekOf: new Date().toISOString().split("T")[0],
},
})),
);
return { dispatched: users.length };
},
);
// ── The worker function: handles exactly one user's digest ─────────────────
export const sendWeeklyDigest = inngest.createFunction(
{
id: "send-weekly-digest",
retries: 3,
concurrency: { limit: 100 }, // At most 100 running simultaneously
},
{ event: "digest/weekly.requested" },
async ({ event, step }) => {
const digestContent = await step.run("build-digest-content", async () => {
return await digestService.buildForUser(
event.data.userId,
event.data.weekOf,
);
});
await step.run("send-digest-email", async () => {
await emailService.send({
to: event.data.email,
subject: `Your weekly digest — week of ${event.data.weekOf}`,
html: digestContent.html,
text: digestContent.text,
});
});
return { userId: event.data.userId, status: "sent" };
},
);
With 50,000 users, this pattern:
- Keeps the cron function fast (it just loads a list and sends events — done in seconds)
- Gives each user's digest its own run trace in the dashboard
- Gives each user's digest its own retry budget — if user #23,451's email fails, only that user's run retries
- Processes all 50,000 digests in parallel, limited to 100 concurrent by the
concurrencysetting
Part 6: Real-World Cron Patterns
Pattern 1: Daily data cleanup
export const dailyDataCleanup = inngest.createFunction(
{ id: "daily-data-cleanup", retries: 2 },
{ cron: "0 3 * * *" }, // Every day at 3:00 AM UTC
async ({ step }) => {
const stats = await step.run("delete-expired-sessions", async () => {
const result = await db.sessions.deleteWhere({
expiresAt: { lessThan: new Date() },
});
return { deleted: result.count };
});
await step.run("archive-old-logs", async () => {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 90); // 90 days ago
await db.logs.archiveWhere({ createdAt: { lessThan: cutoff } });
});
await step.run("vacuum-orphaned-files", async () => {
const orphans = await storageService.findOrphaned();
await storageService.deleteMany(orphans.map((f) => f.key));
return { cleaned: orphans.length };
});
return { status: "cleanup complete", sessionStats: stats };
},
);
Pattern 2: Hourly health check with alerting
export const hourlyHealthCheck = inngest.createFunction(
{
id: "hourly-health-check",
retries: 1, // Low retries — if the health check itself fails, that's noteworthy
onFailure: async ({ event, error }) => {
await alertingService.page({
severity: "critical",
message: `Health check function failed: ${error.message}`,
});
},
},
{ cron: "0 * * * *" }, // Every hour on the hour
async ({ step }) => {
const results = await step.run("run-health-checks", async () => {
const [dbPing, cacheStatus, externalApiStatus] = await Promise.all([
db.ping(),
cacheService.ping(),
externalApi.healthCheck(),
]);
return { db: dbPing, cache: cacheStatus, api: externalApiStatus };
});
// Alert if any service is degraded
await step.run("evaluate-and-alert", async () => {
const failures = Object.entries(results)
.filter(([, status]) => status !== "healthy")
.map(([service]) => service);
if (failures.length > 0) {
await alertingService.notify({
severity: "warning",
message: `Health check degradation detected: ${failures.join(", ")}`,
details: results,
});
}
await db.healthChecks.insert({
checkedAt: new Date().toISOString(),
results,
allHealthy: failures.length === 0,
});
});
return results;
},
);
Pattern 3: Monthly invoice generation
export const monthlyInvoiceRun = inngest.createFunction(
{
id: "monthly-invoice-run",
retries: 5, // Mission-critical — retry aggressively
onFailure: async ({ event, error }) => {
await alertingService.page({
severity: "high",
message: `Monthly invoice run failed: ${error.message}`,
});
},
},
{ cron: "TZ=America/New_York 0 6 1 * *" }, // 1st of every month at 6 AM New York
async ({ step }) => {
const period = await step.run("compute-billing-period", async () => {
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth(), 0);
return {
start: periodStart.toISOString().split("T")[0],
end: periodEnd.toISOString().split("T")[0],
month: periodStart.toLocaleString("en-US", {
month: "long",
year: "numeric",
}),
};
});
const accounts = await step.run("fetch-billable-accounts", async () => {
return await db.accounts
.where({ status: "active", billingEnabled: true })
.select(["id", "email", "plan", "billingEmail"])
.all();
});
// Fan out — generate and send each invoice independently
await step.sendEvent(
"fan-out-invoice-generation",
accounts.map((account) => ({
name: "invoice/generate.requested",
data: {
accountId: account.id,
billingEmail: account.billingEmail,
plan: account.plan,
periodStart: period.start,
periodEnd: period.end,
periodLabel: period.month,
},
})),
);
return { period, invoicesQueued: accounts.length };
},
);
Pattern 4: Multi-timezone scheduled notifications
When your user base is global and users care about receiving notifications "at 9 AM their time," you need to handle multiple timezones. One approach: run a cron every hour and send notifications to users whose local time is currently 9 AM.
export const globalDailyNotification = inngest.createFunction(
{ id: "global-daily-notification" },
{ cron: "0 * * * *" }, // Every hour — check which users are at "9 AM" right now
async ({ step }) => {
const targetHour = 9; // Send at 9 AM local time
const usersToNotify = await step.run(
"find-users-at-target-hour",
async () => {
// Find all timezone offsets that are currently at the target hour
const now = new Date();
const currentUtcHour = now.getUTCHours();
// A user in UTC-5 sees 9 AM when UTC is 14:00 (2 PM)
// A user in UTC+3 sees 9 AM when UTC is 06:00
// Calculate which UTC offset is at 9 AM right now
const targetUtcHour = (targetHour - 0 + 24) % 24; // UTC+0 case
return await db.users
.where({
status: "active",
notificationsEnabled: true,
// Users whose local hour matches target — simplified example
// In practice, query by timezone offset
localHourNow: targetHour,
})
.all();
},
);
if (usersToNotify.length === 0) return { notified: 0 };
await step.sendEvent(
"fan-out-notifications",
usersToNotify.map((user) => ({
name: "notification/daily.send",
data: { userId: user.id, email: user.email },
})),
);
return { notified: usersToNotify.length };
},
);
Part 7: Cron Triggers vs step.sleep() and step.sleepUntil()
There are three ways to schedule work in Inngest. Knowing which to reach for matters:
| Approach | Use when |
|---|---|
{ cron: "..." } | Work should repeat on a fixed schedule indefinitely |
step.sleep("id", duration) | Work should run once after a delay, triggered by a specific event |
step.sleepUntil("id", datetime) | Work should run at a specific point in time, determined by event data |
// Cron: repeat forever on a schedule — no triggering event
export const weeklyReport = inngest.createFunction(
{ id: "weekly-report" },
{ cron: "0 9 * * MON" },
async ({ step }) => {
/* runs every Monday forever */
},
);
// step.sleep: delay within a flow — runs once, triggered by signup
export const signupFlow = inngest.createFunction(
{ id: "signup-flow" },
{ event: "user/signed.up" },
async ({ event, step }) => {
await step.run("send-welcome", () =>
emailService.sendWelcome(event.data.email),
);
await step.sleep("wait-3-days", "3d");
await step.run("send-checkin", () =>
emailService.sendCheckin(event.data.email),
);
},
);
// step.sleepUntil: run at a user-specified time — the time comes from the event
export const sendReminder = inngest.createFunction(
{ id: "send-reminder" },
{ event: "reminder/scheduled" },
async ({ event, step }) => {
// event.data.remindAt is an ISO timestamp the user chose
await step.sleepUntil(
"wait-until-reminder-time",
new Date(event.data.remindAt),
);
await step.run("send-reminder-message", async () => {
await notificationService.send(event.data.userId, event.data.message);
});
},
);
The rule: use a cron trigger for work that repeats on a fixed schedule regardless of any event. Use step.sleep() or step.sleepUntil() for delays within a flow that was triggered by something specific.
Common Misconceptions
❌ Misconception: Cron functions receive event data
Reality: Cron functions are triggered by time, not by events. The handler receives no event argument — there's nothing to receive. If you need to pass data into a scheduled function, you can compute it inside a step (e.g., "what is today's date", "what accounts are due for billing").
// ❌ This will error — cron functions have no event
async ({ event, step }) => {
console.log(event.data.userId); // TypeError: Cannot read properties of undefined
};
// ✅ Correct — no event parameter
async ({ step }) => {
// Compute what you need inside the function
const today = new Date().toISOString().split("T")[0];
};
❌ Misconception: Cron functions run at exactly the right second
Reality: Cron triggers fire at approximately the scheduled time — within a few seconds of the target. Inngest does not guarantee exact-second execution. For the vast majority of use cases (daily reports, weekly digests, hourly health checks), a few seconds of variance is completely irrelevant. If you need sub-second precision timing, you need a different architecture.
❌ Misconception: You should test cron functions by changing the schedule to * * * * *
Reality: Use the Dev Server's Invoke button instead. Changing the schedule to "every minute" creates multiple problems: you might forget to change it back before deploying, you'll get dozens of test runs in your logs, and you're not actually testing the same schedule expression that production will use. The Invoke button runs the function exactly as production would, on demand.
❌ Misconception: You need one cron function per timezone
Reality: For timezone-aware notifications, either: (1) run a single UTC cron at a convenient time and use user timezone data to determine who to notify, or (2) run a cron every hour and filter users whose local time matches your target hour. You almost never need N separate cron functions for N timezones.
Troubleshooting Common Issues
Problem: Cron function isn't running at the expected time
Diagnostic steps:
# 1. Verify the expression with crontab.guru
# Paste your expression and confirm the "next run" times are what you expect
# 2. Check the Dev Server's function detail for next scheduled run
# At http://localhost:8288/functions — select your cron function
# 3. Check whether you added a timezone prefix
# Without TZ=..., the cron runs in UTC — not your local time
Common causes:
- Off-by-one in the cron expression (e.g.,
0 9 * * 1means Monday,0 9 * * 0means Sunday) - Missing timezone prefix — UTC times don't match your expectation
- Function not registered in the
serve()handler
Problem: Cron function runs twice during DST change
Why: You scheduled at 2:00 AM in a DST-observing timezone. When clocks "fall back," 2:00 AM occurs twice.
Solution: Reschedule to a time outside the DST window (most DST transitions happen between 1:00–3:00 AM), or switch to UTC.
Problem: Cron is fanning out to thousands of items but overwhelming the email service
Solution: Add a concurrency limit to the worker function:
export const sendDigestEmail = inngest.createFunction(
{
id: "send-digest-email",
concurrency: { limit: 50 }, // Max 50 simultaneous email sends
},
{ event: "digest/weekly.requested" },
async ({ event, step }) => {
/* ... */
},
);
Start conservatively (10–50) and increase as you verify your email provider can handle the load.
Problem: The function ran but I can't see the output
Solution: Check the Inngest Dev Server (local) or Inngest Cloud dashboard (production) Runs tab. Cron function runs are listed alongside event-triggered runs with a clock icon indicating their scheduled trigger. If the run isn't there, the function may not have been registered in serve().
Check Your Understanding
Quick Quiz
1. You want a cron to run "every weekday at 8:30 AM London time." Write the trigger.
Show Answer
{
cron: "TZ=Europe/London 30 8 * * 1-5";
}
Breaking it down:
30— minute 308— hour 8 (8 AM)*— any day of month*— any month1-5— Monday through FridayTZ=Europe/London— London timezone (handles GMT/BST automatically)
2. A cron function loads 5,000 records and processes each one in a loop inside a single step. What's wrong, and how would you fix it?
Show Answer
Processing 5,000 items inside a single step.run() call means:
- If the function crashes at item #3,847, it restarts from item #1 on retry
- You can't see individual item progress in the dashboard — it's one opaque step
- All 5,000 items process sequentially, one at a time
Fix: Use the cron + fan-out pattern. The cron function loads the 5,000 records and calls step.sendEvent() with an array of 5,000 events. A separate worker function handles each record. Each record gets its own run trace, its own retries, and they all run in parallel (up to the concurrency limit you set).
// In the cron function:
await step.sendEvent(
"fan-out",
records.map((r) => ({
name: "record/process.requested",
data: { recordId: r.id },
})),
);
// Worker handles one record independently:
inngest.createFunction(
{ id: "process-record", concurrency: { limit: 50 } },
{ event: "record/process.requested" },
async ({ event, step }) => {
/* process event.data.recordId */
},
);
3. Your cron runs at 0 2 * * * in TZ=America/Chicago. You notice it sometimes skips a run. What's happening and how do you fix it?
Show Answer
2:00 AM is the DST transition hour in North America. When clocks "spring forward," the hour from 2:00 AM to 3:00 AM is skipped entirely — 2:00 AM simply doesn't exist that night. Your cron doesn't run because the scheduled moment never occurred.
Fix: Move the schedule to a time outside the DST transition window — 3:00 AM, 4:00 AM, or any time that reliably exists. Or switch to UTC if consistent system timing matters more than local time:
// Before (risky):
{
cron: "TZ=America/Chicago 0 2 * * *";
}
// After (safe options):
{
cron: "TZ=America/Chicago 0 4 * * *";
} // 4 AM, outside DST window
{
cron: "0 8 * * *";
} // 8 AM UTC = ~2-3 AM Chicago
Hands-On Challenge
Build a complete "weekly activity report" system using Inngest:
Requirements:
- Every Monday at 8:00 AM UTC, generate and send a weekly activity summary to every active user
- Each user's report should be generated independently
- If an individual report fails, only that user's report should retry — other users are unaffected
- Limit to 25 concurrent email sends at a time
Hints: You'll need two functions — one cron orchestrator and one event-triggered worker. Use step.sendEvent() to fan out. Set concurrency: { limit: 25 } on the worker.
See a Suggested Solution
// ── Cron orchestrator ──────────────────────────────────────────────────────
export const weeklyActivityReportCron = inngest.createFunction(
{ id: "weekly-activity-report-cron" },
{ cron: "0 8 * * MON" }, // Every Monday at 8 AM UTC
async ({ step }) => {
const users = await step.run("load-active-users", async () => {
return await db.users
.where({ status: "active", weeklyReportsEnabled: true })
.select(["id", "email", "name"])
.all();
});
await step.sendEvent(
"fan-out-weekly-reports",
users.map((user) => ({
name: "report/weekly.generate",
data: {
userId: user.id,
email: user.email,
name: user.name,
weekStarting: new Date().toISOString().split("T")[0],
},
})),
);
return { queued: users.length };
},
);
// ── Worker: one report per user ────────────────────────────────────────────
export const generateWeeklyReport = inngest.createFunction(
{
id: "generate-weekly-report",
retries: 3,
concurrency: { limit: 25 },
},
{ event: "report/weekly.generate" },
async ({ event, step }) => {
const reportData = await step.run("build-report-data", async () => {
return await analyticsService.getUserWeeklySummary(
event.data.userId,
event.data.weekStarting,
);
});
await step.run("send-report-email", async () => {
await emailService.send({
to: event.data.email,
subject: `Your weekly activity summary — ${event.data.weekStarting}`,
template: "weekly-report",
data: {
name: event.data.name,
...reportData,
},
});
});
return { userId: event.data.userId, status: "sent" };
},
);
Summary: Key Takeaways
- Cron trigger syntax:
{ cron: "minute hour day month weekday" }— standard five-field cron expressions - Timezones: Prefix with
TZ=IANA_timezone_name— e.g.,TZ=America/New_York 0 9 * * MON. Default is UTC. - DST warning: Avoid scheduling at 2:00 AM in DST-observing timezones — the cron may skip or double-run. Use UTC or schedule outside the transition window.
- No event parameter: Cron functions have no triggering event — the handler receives
{ step }but notevent. - Testing locally: Use the Dev Server's Invoke button in the Functions tab — don't change the schedule expression to test.
- Use steps: Wrap cron work in
step.run()calls so each piece of work retries independently if it fails. - Cron + fan-out: For large-scale scheduled batch processing, use the cron function to load a list and
step.sendEvent()to spawn one independent worker per item. - Choose the right primitive: Cron for repeating schedules,
step.sleep()for delays within event-triggered flows,step.sleepUntil()for user-defined future times.
What's Next?
You now have the full toolkit for Inngest's core workflow primitives: events, functions, steps, retries, fan-out, event coordination, and scheduled triggers.
In Article 10: Idempotency — Running Functions Safely More Than Once, we tackle the concept that underlies safe distributed systems: ensuring that running your code twice produces exactly the same result as running it once. This is what makes retries safe, what makes fan-out reliable, and what separates production-ready workflow code from fragile, unpredictable background jobs.
Version Information
Tested with:
inngest:^4.1.x- Node.js: v18.x, v20.x, v22.x
- TypeScript: 5.x
Cron expression format: Standard five-field cron (no seconds field). Day-of-week supports both 0–7 numerics and SUN/MON/TUE/WED/THU/FRI/SAT abbreviations.
Further reading:
- Crons (Scheduled Functions) — Inngest Documentation — official cron guide with timezone examples
- Serverless Scheduled & Cron Jobs — Inngest — use case overview and comparison with platform-specific crons
- Running Code on a Schedule — Inngest Patterns — pattern library entry
- crontab.guru — interactive cron expression validator
- IANA Time Zone Database — complete list of IANA timezone identifiers
- Fan-Out: Triggering Multiple Tasks from One Event — essential background for cron + fan-out pattern