| title | Scheduling Pattern 3: Schedule Tasks with Cron Expressions | ||||||
|---|---|---|---|---|---|---|---|
| id | scheduling-pattern-cron-expressions | ||||||
| skillLevel | intermediate | ||||||
| applicationPatternId | scheduling-periodic-tasks | ||||||
| summary | Use cron expressions to schedule tasks at specific times and intervals, enabling calendar-based scheduling with timezone support. | ||||||
| tags |
|
||||||
| rule |
|
||||||
| related |
|
||||||
| author | effect_website | ||||||
| lessonOrder | 2 |
Use cron expressions for scheduling that aligns with business calendars:
- Hourly backups:
0 * * * *(at :00 every hour) - Daily reports:
0 9 * * 1-5(9 AM weekdays) - Monthly cleanup:
0 0 1 * *(midnight on 1st of month) - Business hours:
0 9-17 * * 1-5(9 AM-5 PM, Mon-Fri)
Format: minute hour day month weekday
Fixed intervals don't align with business needs:
Fixed interval (every 24 hours):
- If task takes 2 hours, next run is 26 hours later
- Drifts over time
- No alignment with calendar
- Fails during daylight saving time changes
Cron expressions:
- Specific calendar times (e.g., always 9 AM)
- Independent of execution duration
- Aligns with business hours
- Natural DST handling (clock adjusts, cron resyncs)
- Human-readable vs. milliseconds
Real-world example: Daily report at 9 AM
- Fixed interval: Scheduled at 9:00, takes 1 hour → next at 10:00 → drift until 5 PM
- Cron
0 9 * * *: Always runs at 9:00 regardless of duration or previous delays
This example demonstrates scheduling a daily report generation using cron, with timezone support.
import { Effect, Schedule, Console } from "effect";
import { DateTime } from "luxon"; // For timezone handling
interface ReportConfig {
readonly cronExpression: string;
readonly timezone?: string;
readonly jobName: string;
}
interface ScheduledReport {
readonly timestamp: Date;
readonly jobName: string;
readonly result: string;
}
// Simple cron parser (in production, use a library like cron-parser)
const parseCronExpression = (
expression: string
): {
minute: number[];
hour: number[];
dayOfMonth: number[];
month: number[];
dayOfWeek: number[];
} => {
const parts = expression.split(" ");
const parseField = (field: string, max: number): number[] => {
if (field === "*") {
return Array.from({ length: max + 1 }, (_, i) => i);
}
if (field.includes(",")) {
return field.split(",").flatMap((part) => parseField(part, max));
}
if (field.includes("-")) {
const [start, end] = field.split("-").map(Number);
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
return [Number(field)];
};
return {
minute: parseField(parts[0], 59),
hour: parseField(parts[1], 23),
dayOfMonth: parseField(parts[2], 31),
month: parseField(parts[3], 12),
dayOfWeek: parseField(parts[4], 6),
};
};
// Check if current time matches cron expression
const shouldRunNow = (parsed: ReturnType<typeof parseCronExpression>): boolean => {
const now = new Date();
return (
parsed.minute.includes(now.getUTCMinutes()) &&
parsed.hour.includes(now.getUTCHours()) &&
parsed.dayOfMonth.includes(now.getUTCDate()) &&
parsed.month.includes(now.getUTCMonth() + 1) &&
parsed.dayOfWeek.includes(now.getUTCDay())
);
};
// Generate a report
const generateReport = (jobName: string): Effect.Effect<ScheduledReport> =>
Effect.gen(function* () {
yield* Console.log(`[REPORT] Generating ${jobName}...`);
// Simulate report generation
yield* Effect.sleep("100 millis");
return {
timestamp: new Date(),
jobName,
result: `Report generated at ${new Date().toISOString()}`,
};
});
// Schedule with cron expression
const scheduleWithCron = (config: ReportConfig) =>
Effect.gen(function* () {
const parsed = parseCronExpression(config.cronExpression);
yield* Console.log(
`[SCHEDULER] Scheduling job: ${config.jobName}`
);
yield* Console.log(`[SCHEDULER] Cron: ${config.cronExpression}`);
yield* Console.log(`[SCHEDULER] Timezone: ${config.timezone || "UTC"}\n`);
// Create schedule that checks every minute
const schedule = Schedule.fixed("1 minute").pipe(
Schedule.untilInputEffect((report: ScheduledReport) =>
Effect.gen(function* () {
const isPastTime = shouldRunNow(parsed);
if (isPastTime) {
yield* Console.log(
`[SCHEDULED] ✓ Running at ${report.timestamp.toISOString()}`
);
return true; // Stop scheduling
}
return false; // Continue scheduling
})
)
);
// Generate report with cron schedule
yield* generateReport(config.jobName).pipe(
Effect.repeat(schedule)
);
});
// Demonstrate multiple cron schedules
const program = Effect.gen(function* () {
console.log(
`\n[START] Scheduling multiple jobs with cron expressions\n`
);
// Schedule examples (note: in real app, these would run at actual times)
const jobs = [
{
cronExpression: "0 9 * * 1-5", // 9 AM weekdays
jobName: "Daily Standup Report",
timezone: "America/New_York",
},
{
cronExpression: "0 0 * * *", // Midnight daily
jobName: "Nightly Backup",
timezone: "UTC",
},
{
cronExpression: "0 0 1 * *", // Midnight on 1st of month
jobName: "Monthly Summary",
timezone: "Europe/London",
},
];
yield* Console.log("[JOBS] Scheduled:");
jobs.forEach((job) => {
console.log(
` - ${job.jobName}: ${job.cronExpression} (${job.timezone})`
);
});
});
Effect.runPromise(program);Handle daylight saving time transitions and different timezones:
interface TimezoneConfig extends ReportConfig {
readonly timezone: string;
}
const scheduleWithTimezone = (config: TimezoneConfig) =>
Effect.gen(function* () {
yield* Console.log(`[TIMEZONE] Scheduling in ${config.timezone}`);
// Get current time in specified timezone
const getTimeInTimezone = (): Date => {
const formatter = new Intl.DateTimeFormat("en-US", {
timeZone: config.timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
const parts = formatter.formatToParts(new Date());
const date = new Date();
// Reconstruct date in timezone
return date;
};
// Check if should run (in specified timezone)
const shouldRunInTimezone = (parsed: ReturnType<typeof parseCronExpression>) => {
const now = getTimeInTimezone();
return (
parsed.minute.includes(now.getMinutes()) &&
parsed.hour.includes(now.getHours())
);
};
const parsed = parseCronExpression(config.cronExpression);
// Schedule task
const schedule = Schedule.fixed("1 minute").pipe(
Schedule.untilInput((prev: number) => {
const shouldRun = shouldRunInTimezone(parsed);
if (shouldRun) {
yield* Console.log(
`[TIMEZONE] Running job in ${config.timezone} at ${getTimeInTimezone().toISOString()}`
);
return true;
}
return false;
})
);
yield* generateReport(config.jobName).pipe(
Effect.repeat(schedule)
);
});Prevent multiple instances of the same job from running:
interface CronJobWithLocking {
readonly jobName: string;
readonly cronExpression: string;
readonly maxDurationMs: number;
}
const scheduleWithLocking = (config: CronJobWithLocking) =>
Effect.gen(function* () {
let isRunning = false;
let lastCompletionTime = 0;
const parsed = parseCronExpression(config.cronExpression);
yield* Console.log(
`[LOCKING] Job: ${config.jobName}, max duration: ${config.maxDurationMs}ms`
);
// Schedule with lock
const schedule = Schedule.fixed("1 minute").pipe(
Schedule.untilInputEffect((report: ScheduledReport) =>
Effect.gen(function* () {
const now = Date.now();
const shouldRun = shouldRunNow(parsed);
const isStale = now - lastCompletionTime > config.maxDurationMs * 2;
if (!shouldRun) {
return false;
}
if (isRunning && !isStale) {
yield* Console.log(
`[LOCKING] Job already running, skipping duplicate`
);
return false;
}
if (isStale && isRunning) {
yield* Console.log(
`[LOCKING] Previous instance stale, allowing restart`
);
}
isRunning = true;
try {
const startTime = Date.now();
yield* generateReport(config.jobName).pipe(
Effect.timeout(`${config.maxDurationMs} millis`)
);
const duration = Date.now() - startTime;
lastCompletionTime = Date.now();
yield* Console.log(
`[LOCKING] Job completed in ${duration}ms`
);
} catch (error) {
yield* Console.log(
`[LOCKING] Job failed: ${error}`
);
} finally {
isRunning = false;
}
return false; // Continue scheduling
})
)
);
yield* Schedule.repeat_forever(schedule);
});Update cron expressions at runtime without restarting:
import { Ref } from "effect";
interface DynamicCronConfig {
readonly jobName: string;
readonly initialCron: string;
}
const scheduleDynamicCron = (config: DynamicCronConfig) =>
Effect.gen(function* () {
const cronExpression = yield* Ref.make(config.initialCron);
yield* Console.log(
`[DYNAMIC] Job: ${config.jobName}, initial cron: ${config.initialCron}`
);
// Method to update cron expression
const updateCronExpression = (newExpression: string) =>
Effect.gen(function* () {
const old = yield* Ref.get(cronExpression);
yield* Ref.set(cronExpression, newExpression);
yield* Console.log(
`[DYNAMIC] Updated cron: ${old} → ${newExpression}`
);
});
// Schedule with dynamic cron
const schedule = Schedule.fixed("1 minute").pipe(
Schedule.untilInputEffect((report: ScheduledReport) =>
Effect.gen(function* () {
const currentCron = yield* Ref.get(cronExpression);
const parsed = parseCronExpression(currentCron);
if (shouldRunNow(parsed)) {
yield* Console.log(
`[DYNAMIC] Running with: ${currentCron}`
);
return true;
}
return false;
})
)
);
// Run job
yield* generateReport(config.jobName).pipe(
Effect.repeat(schedule)
);
});
// Example: Update cron every hour
const updateCronPeriodically = (
jobName: string,
schedule: { time: string; cron: string }[]
) =>
Effect.gen(function* () {
const currentHour = new Date().getHours();
const nextSchedule = schedule[currentHour % schedule.length];
// This is a simplified example
yield* Console.log(
`[UPDATE] New schedule for hour ${currentHour}: ${nextSchedule.cron}`
);
});Combine cron scheduling with exponential backoff:
interface CronWithRetryConfig extends CronJobWithLocking {
readonly maxRetries: number;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
}
const scheduleCronWithBackoff = (config: CronWithRetryConfig) =>
Effect.gen(function* () {
const parsed = parseCronExpression(config.cronExpression);
yield* Console.log(
`[CRON+RETRY] Job: ${config.jobName}, max retries: ${config.maxRetries}`
);
const schedule = Schedule.fixed("1 minute").pipe(
Schedule.untilInputEffect((report: ScheduledReport) =>
Effect.gen(function* () {
if (!shouldRunNow(parsed)) {
return false;
}
// Try to execute with backoff
let attempt = 0;
let lastError: Error | undefined;
while (attempt < config.maxRetries) {
try {
yield* generateReport(config.jobName);
return false; // Success, continue scheduling
} catch (error) {
lastError = error as Error;
if (attempt < config.maxRetries - 1) {
const exponential = config.baseDelayMs * Math.pow(2, attempt);
const delay = Math.min(exponential, config.maxDelayMs);
yield* Console.log(
`[RETRY] Attempt ${attempt + 1} failed, retrying in ${delay}ms`
);
yield* Effect.sleep(`${delay} millis`);
}
attempt++;
}
}
if (lastError) {
yield* Console.log(
`[FAILURE] Job failed after ${config.maxRetries} attempts: ${lastError.message}`
);
}
return false; // Continue scheduling for next cron time
})
)
);
yield* Schedule.repeat_forever(schedule);
});✅ Use cron expressions when:
- Running daily/hourly/weekly tasks at specific times
- Business logic tied to clock time (daily reports at 9 AM)
- Calendar-based operations (month-end close, quarterly reviews)
- Need alignment with business hours or timezones
- Long-term recurring tasks
- Tasks that should survive application restarts
- More complex than fixed intervals
- Cron parsing can be CPU intensive (cache results)
- DST transitions require careful handling
- Timezone library dependencies
- Distributed systems need coordination (prevent duplicates)
| Expression | Meaning |
|---|---|
0 0 * * * |
Every day at midnight |
0 9 * * 1-5 |
9 AM Monday-Friday |
0 12 * * 0,6 |
Noon on weekends |
*/15 * * * * |
Every 15 minutes |
0 0 1 * * |
First day of month |
0 0 * * 0 |
Every Sunday |
0 9,17 * * * |
9 AM and 5 PM daily |
30 2 * * * |
2:30 AM daily |
- Scheduling Pattern 1: Repeat on Fixed Interval - Fixed interval scheduling
- Scheduling Pattern 2: Exponential Backoff - Retry with backoff
- Run Background Tasks with Fork - Background execution
- Handle Side Effects with Effect.sync - Effect side effects