5 Cron Expression Patterns Used in Real Production
Cron expressions look simple — five fields, a handful of operators — but production teams make the same mistakes repeatedly: jobs that silently skip on certain months, overlapping runs that corrupt shared state, and hundreds of jobs that all wake up at the same second and overwhelm a database. The expression is rarely the problem; the problem is not thinking through the edge cases.
This guide covers five cron patterns that engineering teams actually use in production, with the reasoning behind each and the traps to avoid. Use the Toova Cron Parser to verify any expression and see the next ten execution times before deploying.
Cron Expression Anatomy
A standard cron expression has five space-separated fields:
*/15 * * * *
| | | | |
| | | | +---- day of week (0-6, 0=Sunday)
| | | +------- month (1-12)
| | +---------- day of month (1-31)
| +------------- hour (0-23)
+----------------- minute: every 15th (0, 15, 30, 45)
Each field accepts: a specific value (5), a wildcard (*), a range (1-5), a list (1,3,5), or a step value (*/15). Some implementations add a sixth field for seconds; others (like GitHub Actions) expect exactly five. Always check which dialect your scheduler uses before writing complex expressions.
Pattern 1: Every 15 Minutes — */15 * * * *
*/15 * * * *
| | | | |
| | | | +---- day of week (0-6, 0=Sunday)
| | | +------- month (1-12)
| | +---------- day of month (1-31)
| +------------- hour (0-23)
+----------------- minute: every 15th (0, 15, 30, 45) The go-to expression for queue processors, heartbeat checks, and polling jobs that need to run frequently but not continuously. It fires at :00, :15, :30, and :45 of every hour, every day.
In a crontab:
# crontab entry — run batch processor every 15 minutes
*/15 * * * * /opt/app/bin/process-queue.sh >> /var/log/queue.log 2>&1 In Node.js:
// Node.js with node-cron
import cron from 'node-cron';
cron.schedule('*/15 * * * *', async () => {
const batch = await db.query(
'SELECT * FROM jobs WHERE status = $1 LIMIT 100',
['pending']
);
await processBatch(batch);
}); The thundering herd problem. When you run hundreds of workers with the same */15 expression, they all fire simultaneously at :00, :15, :30, and :45. If each worker hits a shared database, you get four spikes per hour instead of a smooth load. The fix is to add random jitter before each run:
// Add jitter to avoid thundering herd on distributed systems
cron.schedule('*/15 * * * *', async () => {
const jitterMs = Math.floor(Math.random() * 30_000); // up to 30s
await sleep(jitterMs);
await processBatch();
}); Up to 30 seconds of jitter spreads the spike across a 30-second window while keeping the job within the expected 15-minute cycle. For more sophisticated staggering, look at Pattern 5 (Jenkins H syntax).
Use the Timestamp Converter to translate your next scheduled run from Unix epoch to a human-readable time when debugging job timing.
Pattern 2: Weekday Business Hours — 0 9 * * 1-5
0 9 * * 1-5
| | | | |
| | | | +-- day of week: Monday–Friday (1-5)
| | | +------ month: every month
| | +---------- day of month: every day
| +-------------- hour: 9
+------------------ minute: 0 (exactly on the hour) This expression fires once, at exactly 9:00 AM, Monday through Friday. It is the standard pattern for: sending daily digest emails, running business-hours reports, triggering workflows that need human review during the workday, and deploying to staging at the start of the engineering day.
In a Kubernetes CronJob:
# Kubernetes CronJob — weekday digest email at 9 AM UTC
apiVersion: batch/v1
kind: CronJob
metadata:
name: digest-email
spec:
schedule: "0 9 * * 1-5"
jobTemplate:
spec:
template:
spec:
containers:
- name: mailer
image: myapp/mailer:latest
command: ["/app/send-digest"]
restartPolicy: OnFailure Timezone trap. Kubernetes CronJob schedules run in the cluster's timezone, which is UTC by default. "9 AM UTC" is 5 AM Eastern in winter and 4 AM Pacific. Your users will receive their "morning digest" in the middle of the night. Use a cron library that accepts IANA timezone strings:
// Handle timezone — "9 AM UTC" is rarely what users expect
// Use a library that understands IANA timezones
import { CronJob } from 'cron';
const job = new CronJob(
'0 9 * * 1-5',
() => sendDailyDigest(),
null,
true,
'America/New_York' // runs at 9 AM Eastern, not 9 AM UTC
); The Timezone Converter helps you find the UTC equivalent for a given local time across any IANA timezone.
Day-of-week numbering. The standard cron definition uses 0 or 7 for Sunday. Some implementations (Quartz, cron4j) use 1 for Monday instead of 0. Check your scheduler's documentation and test with Cron Parser before deploying.
Pattern 3: Nightly Batch — 0 22 * * *
0 22 * * *
| | | | |
| | | | +-- day of week: any
| | | +------ month: any
| | +---------- day of month: any
| +-------------- hour: 22 (10 PM)
+------------------- minute: 0 Runs once per day at 10 PM. The nightly batch is one of the most common patterns in data engineering: aggregate the day's events, rebuild search indexes, generate reports, archive logs, send end-of-day notifications.
Why 10 PM instead of midnight? Two reasons. First, 10 PM gives a buffer before the day rolls over, reducing the chance of picking up events that arrive late. Second, running a heavy job at midnight means it competes with month-end billing (Pattern 4) on the first of every month.
The three most common ways to write a daily job:
# Three ways to write "nightly at 10 PM":
0 22 * * * # standard cron
@daily # midnight — NOT the same as 10 PM
0 22 * * * # always prefer explicit time over @macros for non-midnight Overlap prevention. A nightly job that normally takes 45 minutes can occasionally take 2 hours — and if it is still running when the next night's trigger fires, both jobs will modify the same data. Use a distributed lock:
// Idempotency check — prevent double-processing if job overlaps
async function runNightlyBatch() {
const lock = await redis.set(
'nightly-batch-lock',
process.pid,
'EX', 3600, // TTL: 1 hour
'NX' // only set if not exists
);
if (!lock) {
console.log('Batch already running, skipping.');
return;
}
try {
await processNightlyData();
} finally {
await redis.del('nightly-batch-lock');
}
}
The Redis NX flag ensures only one process can hold the lock at a time. The TTL prevents a crashed job from holding the lock forever.
Pattern 4: Monthly Billing — 0 0 1 * *
0 0 1 * *
| | | | |
| | | | +-- day of week: any
| | | +------ month: any
| | +---------- day of month: 1 (first)
| +-------------- hour: 0 (midnight)
+------------------ minute: 0 Fires at midnight on the first day of every month. The classic pattern for: generating monthly invoices, resetting usage quotas, running month-end reconciliation, and archiving the previous month's data.
Cron traps for monthly jobs:
# First of month at midnight UTC
0 0 1 * *
# ---- TRAPS TO AVOID ----
# This does NOT run "every month" reliably:
0 0 29-31 * * # months with fewer days silently skip
# Quarter billing (Jan, Apr, Jul, Oct):
0 0 1 1,4,7,10 *
# End of month is tricky — there is no "last day" in standard cron
# Use @reboot + application logic, or a cron that runs daily and
# checks whether today is the last day of the month in code. End-of-month billing. If you need to bill on the last day of each month (rather than the first), there is no clean cron expression for it. The workaround:
// Application-level "last day of month" check
cron.schedule('0 23 28-31 * *', async () => {
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
// If tomorrow is the 1st, today is the last day of the month
if (tomorrow.getDate() === 1) {
await runEndOfMonthBilling();
}
}); This fires on days 28–31 of every month, then checks in code whether today is actually the last day. It runs up to four times unnecessarily (on months with fewer than 31 days), but the check is cheap and the logic is clear.
For quarter-start billing, use the list operator in the month field: 0 0 1 1,4,7,10 * (January, April, July, October).
Pattern 5: Hashed Schedule (Jenkins H Syntax) — H/30 * * * *
H/30 * * * *
|
+--- H means Jenkins picks a random minute per job.
H/30 = once every 30 minutes, starting at H.
Different jobs get different H values, staggering load.
The H token is a Jenkins-specific extension to cron syntax. It replaces H with a stable pseudo-random number derived from the job name. H/30 in the minute field means "once every 30 minutes, starting at a minute determined by a hash of the job name."
In a Jenkinsfile:
// Jenkinsfile — pipeline triggered every 30 minutes, staggered
pipeline {
triggers {
cron('H/30 * * * *')
}
stages {
stage('Deploy') {
steps {
sh './deploy.sh'
}
}
}
} Why this matters. Jenkins installations often have hundreds of pipelines. If every pipeline uses */30 * * * *, they all trigger at :00 and :30, creating massive load spikes on the agent pool and any downstream systems. With H/30, each job gets a different offset — one at :03/:33, another at :17/:47 — distributing load evenly across the hour.
GitHub Actions does not support H. For staggering multiple Actions workflows, pick different fixed minute offsets manually:
// GitHub Actions — H is not supported; use schedule with offset manually
// To stagger two jobs: pick different fixed minutes
name: Job A
on:
schedule:
- cron: '5 * * * *' # runs at :05 of every hour
---
name: Job B
on:
schedule:
- cron: '35 * * * *' # runs at :35 — 30 minutes after Job A This is less elegant than Jenkins H but achieves the same effect for a small number of workflows. For large numbers of workflows, consider an external orchestrator that natively supports distributed scheduling.
Cross-Cutting Concerns
Idempotency Is Non-Negotiable
Every cron job should be safe to run twice. Infrastructure fails, schedulers restart, and distributed locks occasionally time out. Design jobs so that running them a second time — with the same inputs, at the same time — produces the same result. Use upserts instead of inserts, check-before-act patterns, and idempotency keys for external API calls.
Dead Letter Alerting
A cron job that fails silently is worse than one that does not run at all. Send an alert to a dead letter queue or monitoring system when a job fails or does not complete within its expected time window. Tools like Sentry Crons, Healthchecks.io, and Cronitor add a heartbeat check: the job pings a URL when it starts and when it finishes. If the finish ping does not arrive within the deadline, an alert fires.
Log the Schedule
Emit a structured log line at the start and end of every cron run, including the expression, the triggered-at timestamp (not the current time, which may differ slightly due to jitter), and the duration. This makes it straightforward to correlate a downstream incident — "the database slowed down at 10 PM" — with a specific batch job run.
Quick Reference
Parse and validate any expression with the Cron Parser. Convert scheduled times between timezones with the Timezone Converter. If your job logs Unix timestamps, the Timestamp Converter turns them into readable dates instantly.
For a comprehensive reference of cron syntax and common examples, crontab.guru is the canonical quick reference. For Jenkins H syntax and its full specification, see the Jenkins Pipeline cron syntax documentation.