From String to Timeline: Building a Cron Expression Parser
From String to Timeline: Building a Cron Expression Parser#
I was building a scheduled task management dashboard and needed users to configure execution times. Initially, I thought about letting them enter Cron expressions directly. The product manager shut that down: “You expect ops people to type 0 0 9 * * 1-5? They’ll just say ‘weekdays at 9 AM’.”
Fair point. Cron expressions are second nature to developers, but cryptic to everyone else. So I built a bidirectional tool: users select times, it generates Cron; they enter Cron, it visualizes the execution timeline.
The Essence of Cron#
Cron is a time matching rule. Standard format has 5 fields:
minute hour day month weekday
Some systems support 6 fields, adding seconds at the start:
second minute hour day month weekday
Each field has a specific range:
| Field | Range | Description |
|---|---|---|
| second | 0-59 | Which second |
| minute | 0-59 | Which minute |
| hour | 0-23 | Which hour |
| day | 1-31 | Which day of month |
| month | 1-12 | Which month |
| weekday | 0-6 | Which day of week (0=Sunday) |
Each field supports special syntax:
*- any value5- specific value1-5- range*/5- step (every 5 units)1,3,5- listL- last day (day field only)
Core Algorithm: Time Matching#
The core is checking if a given time matches the Cron expression.
Single Field Matching#
First, implement matching for a single field:
function matchesCronField(value: number, field: string, min: number, max: number): boolean {
// * matches any value
if (field === '*') return true
// Support comma-separated list: 1,3,5
const parts = field.split(',')
for (const part of parts) {
const p = part.trim()
// Handle step: */5 or 1-10/2
if (p.includes('/')) {
const slashIdx = p.indexOf('/')
const range = p.substring(0, slashIdx)
const step = parseInt(p.substring(slashIdx + 1))
if (isNaN(step) || step <= 0) continue
let start = min
let end = max
if (range !== '*') {
if (range.includes('-')) {
const dashIdx = range.indexOf('-')
start = parseInt(range.substring(0, dashIdx))
end = parseInt(range.substring(dashIdx + 1))
} else {
start = parseInt(range)
}
}
// Check if in range and matches step
if (value >= start && value <= end && (value - start) % step === 0) return true
continue
}
// Handle range: 1-5
if (p.includes('-')) {
const dashIdx = p.indexOf('-')
const start = parseInt(p.substring(0, dashIdx))
const end = parseInt(p.substring(dashIdx + 1))
if (!isNaN(start) && !isNaN(end) && value >= start && value <= end) return true
continue
}
// Fixed value
const num = parseInt(p)
if (!isNaN(num) && value === num) return true
}
return false
}
This function handles all Cron field syntax: *, 5, 1-5, */5, 1-10/2, 1,3,5.
Full Time Matching#
With single field matching, full time matching is straightforward:
function matchesCron(date: 2026-02-02T11:29:49+00:00
// Check second
if (!matchesCronField(date.getSeconds(), second, 0, 59)) return false
// Check minute
if (!matchesCronField(date.getMinutes(), minute, 0, 59)) return false
// Check hour
if (!matchesCronField(date.getHours(), hour, 0, 23)) return false
// Handle L (last day)
if (day === 'L') {
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
if (date.getDate() !== lastDay) return false
} else if (!matchesCronField(date.getDate(), day, 1, 31)) {
return false
}
// Check month
if (!matchesCronField(date.getMonth() + 1, month, 1, 12)) return false
// Check weekday (note: both 0 and 7 mean Sunday)
const dow = date.getDay()
if (!matchesCronField(dow, weekday, 0, 6)) {
if (dow !== 0 || !matchesCronField(7, weekday, 0, 7)) {
return false
}
}
return true
}
Key details:
- L handling: Calculate the last day of current month, check if date matches
- Sunday special case: Some systems use 0 for Sunday, others use 7, support both
- Month starts at 1: JavaScript’s
getMonth()returns 0-11, so add 1
Computing Next Executions#
With the matching function, computing next executions is checking each time point:
function getNextExecutions(cron: string, count: number = 10): Date[] {
const parts = cron.trim().split(/\s+/)
if (parts.length < 5 || parts.length > 6) return []
// Parse fields
const isSixField = parts.length === 6
const second = isSixField ? parts[0] : '0'
const minute = isSixField ? parts[1] : parts[0]
const hour = isSixField ? parts[2] : parts[1]
const day = isSixField ? parts[3] : parts[2]
const month = isSixField ? parts[4] : parts[3]
const weekday = isSixField ? parts[5] : parts[4]
const results: Date[] = []
let current = new Date()
current.setMilliseconds(0)
current.setSeconds(current.getSeconds() + 1) // Start from next second
// Optimization: determine step based on second field
const stepMs = (second === '*' || second.includes('/')) ? 1000 : 60000
while (results.length < count) {
if (matchesCron(current, second, minute, hour, day, month, weekday)) {
results.push(new Date(current))
}
// Increment by step
if (stepMs === 60000) {
current.setSeconds(0)
current.setMinutes(current.getMinutes() + 1)
} else {
current = new Date(current.getTime() + 1000)
}
// Prevent infinite loop: stop after one year
if (current.getTime() - startTime > 365 * 24 * 60 * 60 * 1000) {
break
}
}
return results
}
Performance Optimization#
Checking every second is too slow. Several optimizations:
- Step optimization: If second field is fixed (like
0), increment by minute, not second - Skip optimization: If current minute doesn’t match, skip to next potential match
- Cache optimization: For fixed Cron expressions, cache the matching pattern
// Step optimization example
const stepMs = (second === '*' || second.includes('/')) ? 1000 : 60000
if (stepMs === 60000) {
// Increment by minute
current.setSeconds(0)
current.setMinutes(current.getMinutes() + 1)
} else {
// Increment by second
current = new Date(current.getTime() + 1000)
}
For expressions like 0 0 9 * * 1-5 (weekdays at 9 AM), this optimization gives 60x speedup.
Edge Cases I Encountered#
1. Day and Weekday Conflict#
In Cron standard, day and weekday are “OR” relation, not “AND”:
0 0 0 15 * 1 = 15th of each month OR every Monday, NOT "15th that is also Monday"
But some implementations use “AND”. We made this clear in our tool to avoid confusion.
2. February 30th#
0 0 0 30 2 * will never execute in most years. The tool should detect this in timeline calculation and show a warning.
3. Timezone Issues#
Cron expressions don’t include timezone. If server is UTC+8 and user is UTC+5, clarify which timezone the expression is based on.
// Recommend showing timezone in the tool
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
console.log(`Current timezone: ${timezone}`) // America/New_York
4. L Calculation#
Calculate last day of month:
// Day 0 of next month = last day of this month
const lastDay = new Date(year, month + 1, 0).getDate()
This trick is practical, avoiding complex leap year logic.
Visual Timeline#
With getNextExecutions, visualization is simple:
const executions = getNextExecutions('0 0 9 * * 1-5', 10)
executions.forEach((date, i) => {
console.log(`${i + 1}. ${date.toLocaleString()}`)
})
// Output:
// 1. 4/27/2026, 9:00:00 AM (Monday)
// 2. 4/28/2026, 9:00:00 AM (Tuesday)
// 3. 4/29/2026, 9:00:00 AM (Wednesday)
// ...
In the UI, display these in a list so users can verify their configuration at a glance.
The Result#
Based on these ideas, I built: Cron Expression Generator
Features:
- Visual time configuration, auto-generate Cron expression
- Enter Cron expression, show next 50 execution times
- 26 common presets (every minute, every hour, weekdays at 9 AM, etc.)
- Support 6-field format (with seconds)
The core algorithm isn’t complex, but handling edge cases takes effort. Hope this helps.
Related: Timestamp Converter | Base64 Encoder/Decoder