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 value
  • 5 - specific value
  • 1-5 - range
  • */5 - step (every 5 units)
  • 1,3,5 - list
  • L - 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:

  1. L handling: Calculate the last day of current month, check if date matches
  2. Sunday special case: Some systems use 0 for Sunday, others use 7, support both
  3. 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:

  1. Step optimization: If second field is fixed (like 0), increment by minute, not second
  2. Skip optimization: If current minute doesn’t match, skip to next potential match
  3. 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