TL;DR
rrule.js parses and generates iCalendar RRULE recurrence rules — "every Tuesday and Thursday at 10am", "first Monday of every month", complex calendar recurrences with exclusions and timezones. cron-parser parses cron expressions — */5 * * * *, validates syntax, calculates next/previous occurrences, supports seconds and timezones. later defines schedules with a readable API — schedule definitions, composite schedules, exception handling, text parsing. In 2026: rrule.js for calendar-style recurrences (RRULE), cron-parser for cron expression parsing, later for readable schedule definitions.
Key Takeaways
- rrule.js: ~800K weekly downloads — iCalendar RRULE, calendar recurrences, exclusions
- cron-parser: ~10M weekly downloads — cron expressions, next/prev occurrence, validation
- later: ~1M weekly downloads — schedule definitions, text parsing, composite schedules
- rrule.js follows the iCalendar RFC 5545 standard
- cron-parser is the most popular — cron is the universal scheduling language
- later has the most readable schedule definition syntax
rrule.js
rrule.js — iCalendar recurrence rules:
Basic rules
import { RRule, RRuleSet, rrulestr } from "rrule"
// Every weekday at 10am:
const rule = new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR],
byhour: [10],
byminute: [0],
dtstart: new Date(Date.UTC(2026, 0, 1)),
})
// Get next 5 occurrences:
const dates = rule.all((_, i) => i < 5)
console.log(dates)
// [Mon Jan 5, Tue Jan 6, Wed Jan 7, Thu Jan 8, Fri Jan 9]
// Human-readable text:
console.log(rule.toText())
// "every week on Monday, Tuesday, Wednesday, Thursday, Friday at 10:00"
// To RRULE string:
console.log(rule.toString())
// "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=10;BYMINUTE=0"
Complex recurrences
import { RRule } from "rrule"
// First Monday of every month:
const firstMonday = new RRule({
freq: RRule.MONTHLY,
byweekday: [RRule.MO.nth(1)],
dtstart: new Date(Date.UTC(2026, 0, 1)),
})
// Every other week on Tuesday and Thursday:
const biweekly = new RRule({
freq: RRule.WEEKLY,
interval: 2,
byweekday: [RRule.TU, RRule.TH],
dtstart: new Date(Date.UTC(2026, 0, 1)),
})
// Last Friday of every quarter:
const quarterly = new RRule({
freq: RRule.MONTHLY,
interval: 3,
byweekday: [RRule.FR.nth(-1)],
dtstart: new Date(Date.UTC(2026, 0, 1)),
})
// Until a specific date:
const limited = new RRule({
freq: RRule.DAILY,
dtstart: new Date(Date.UTC(2026, 0, 1)),
until: new Date(Date.UTC(2026, 11, 31)),
})
// Count-limited:
const counted = new RRule({
freq: RRule.WEEKLY,
count: 10,
byweekday: [RRule.MO],
dtstart: new Date(Date.UTC(2026, 0, 1)),
})
RRuleSet (exclusions and additions)
import { RRule, RRuleSet } from "rrule"
const set = new RRuleSet()
// Base rule — every weekday:
set.rrule(new RRule({
freq: RRule.WEEKLY,
byweekday: [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR],
dtstart: new Date(Date.UTC(2026, 0, 1)),
}))
// Exclude holidays:
set.exdate(new Date(Date.UTC(2026, 0, 1))) // New Year's Day
set.exdate(new Date(Date.UTC(2026, 6, 4))) // July 4th
set.exdate(new Date(Date.UTC(2026, 11, 25))) // Christmas
// Exclude date range (vacation):
set.exrule(new RRule({
freq: RRule.DAILY,
dtstart: new Date(Date.UTC(2026, 7, 1)),
until: new Date(Date.UTC(2026, 7, 15)),
}))
// Add specific extra dates:
set.rdate(new Date(Date.UTC(2026, 0, 3))) // Saturday makeup day
// Get occurrences:
const dates = set.between(
new Date(Date.UTC(2026, 0, 1)),
new Date(Date.UTC(2026, 1, 1)),
)
Parse RRULE strings
import { rrulestr } from "rrule"
// Parse from iCalendar string:
const rule = rrulestr("RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;BYHOUR=9;BYMINUTE=0")
console.log(rule.toText())
// "every week on Monday, Wednesday, Friday at 9:00"
console.log(rule.all((_, i) => i < 3))
// Next 3 occurrences
// Parse with DTSTART:
const ruleWithStart = rrulestr(
"DTSTART:20260101T090000Z\nRRULE:FREQ=MONTHLY;BYMONTHDAY=1"
)
// Parse full iCalendar set:
const ruleSet = rrulestr(
"DTSTART:20260101T090000Z\n" +
"RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\n" +
"EXDATE:20260106T090000Z",
{ forceset: true }
)
cron-parser
cron-parser — cron expression parsing:
Basic parsing
import parser from "cron-parser"
// Parse cron expression:
const interval = parser.parseExpression("*/5 * * * *") // Every 5 minutes
// Next occurrence:
console.log(interval.next().toDate())
// 2026-03-09T10:05:00.000Z
// Previous occurrence:
console.log(interval.prev().toDate())
// 2026-03-09T09:55:00.000Z
// Iterate next 5:
for (let i = 0; i < 5; i++) {
console.log(interval.next().toDate())
}
// Reset iterator:
interval.reset()
Common expressions
import parser from "cron-parser"
// Standard cron (5 fields: min hour dom month dow):
parser.parseExpression("0 9 * * *") // Every day at 9am
parser.parseExpression("0 9 * * 1-5") // Weekdays at 9am
parser.parseExpression("0 0 1 * *") // First of every month
parser.parseExpression("0 0 * * 0") // Every Sunday midnight
parser.parseExpression("30 8 * * 1") // Monday 8:30am
parser.parseExpression("0 */2 * * *") // Every 2 hours
parser.parseExpression("0 9,17 * * *") // 9am and 5pm
// With seconds (6 fields):
parser.parseExpression("*/30 * * * * *") // Every 30 seconds
parser.parseExpression("0 0 9 * * 1-5") // Weekdays at 9am (with seconds)
Options and timezones
import parser from "cron-parser"
// With timezone:
const interval = parser.parseExpression("0 9 * * *", {
tz: "America/New_York",
})
console.log(interval.next().toDate()) // 9am ET
// With date range:
const bounded = parser.parseExpression("0 9 * * *", {
currentDate: new Date("2026-03-01"),
startDate: new Date("2026-03-01"),
endDate: new Date("2026-03-31"),
tz: "America/Los_Angeles",
})
// Iterate within range:
try {
while (true) {
console.log(bounded.next().toDate())
}
} catch (e) {
// "Out of the timespan range" when past endDate
}
// With iterator:
const iter = parser.parseExpression("0 9 * * 1-5", {
iterator: true,
})
const result = iter.next()
console.log(result.value.toDate())
console.log(result.done) // false
Validation
import parser from "cron-parser"
// Validate expression:
function isValidCron(expression: string): boolean {
try {
parser.parseExpression(expression)
return true
} catch {
return false
}
}
console.log(isValidCron("*/5 * * * *")) // true
console.log(isValidCron("0 9 * * 1-5")) // true
console.log(isValidCron("invalid")) // false
console.log(isValidCron("60 * * * *")) // false (minutes 0-59)
// Get fields:
const fields = parser.fieldsToExpression(
parser.parseExpression("0 9 * * 1-5").fields
)
console.log(fields.stringify()) // "0 9 * * 1-5"
later
later — schedule definitions:
Basic schedules
import later from "@breejs/later"
// Parse text:
const schedule = later.parse.text("every 5 minutes")
// Next occurrence:
const next = later.schedule(schedule).next(1)
console.log(next)
// Next 5 occurrences:
const next5 = later.schedule(schedule).next(5)
console.log(next5)
// Previous occurrence:
const prev = later.schedule(schedule).prev(1)
console.log(prev)
Text parser
import later from "@breejs/later"
// Readable schedule definitions:
later.parse.text("every weekday at 9:00 am")
later.parse.text("every Monday at 10:00 am")
later.parse.text("on the first day of every month")
later.parse.text("every 30 minutes")
later.parse.text("at 9:00 am and 5:00 pm")
later.parse.text("on the last Friday of every month")
later.parse.text("every 2nd hour")
// Parse cron too:
later.parse.cron("0 9 * * 1-5") // Standard cron
later.parse.cron("0 0 9 * * 1-5") // With seconds
Recurrence builder
import later from "@breejs/later"
// Programmatic schedule definition:
const weekdays9am = later.parse.recur()
.on(2, 3, 4, 5, 6).dayOfWeek() // Mon-Fri (1=Sun)
.on("09:00").time()
// Every 15 minutes during business hours:
const businessHours = later.parse.recur()
.every(15).minute()
.after("09:00").time()
.before("17:00").time()
.on(2, 3, 4, 5, 6).dayOfWeek()
// First and fifteenth of each month:
const payDays = later.parse.recur()
.on(1, 15).dayOfMonth()
.on("09:00").time()
// Get occurrences:
const sched = later.schedule(weekdays9am)
const nextDates = sched.next(5, new Date("2026-03-01"))
console.log(nextDates)
Composite schedules (AND/OR/EXCEPT)
import later from "@breejs/later"
// Composite — multiple schedules OR'd together:
const composite = {
schedules: [
// Every weekday at 9am:
later.parse.recur()
.on(2, 3, 4, 5, 6).dayOfWeek()
.on("09:00").time(),
// Also every Saturday at noon:
later.parse.recur()
.on(7).dayOfWeek()
.on("12:00").time(),
],
exceptions: [
// Except holidays (first of each month as example):
later.parse.recur()
.on(1).dayOfMonth(),
],
}
const sched = later.schedule(composite)
const dates = sched.next(10, new Date("2026-03-01"))
Set timezone
import later from "@breejs/later"
// Use local time (default is UTC):
later.date.localTime()
// Use UTC:
later.date.UTC()
// Execute on schedule:
const schedule = later.parse.text("every 5 minutes")
const timer = later.setInterval(() => {
console.log("Running task at:", new Date())
}, schedule)
// Stop:
timer.clear()
// Set timeout for next occurrence:
const timeout = later.setTimeout(() => {
console.log("Next occurrence!")
}, schedule)
timeout.clear()
Feature Comparison
| Feature | rrule.js | cron-parser | later |
|---|---|---|---|
| Format | iCalendar RRULE | Cron expressions | Text/recurrence API |
| Standard | RFC 5545 | POSIX cron | Custom |
| Next occurrence | ✅ | ✅ | ✅ |
| Previous occurrence | ✅ | ✅ | ✅ |
| Date range query | ✅ (between) | ✅ (startDate/endDate) | ✅ (next with start) |
| Exclusions | ✅ (RRuleSet) | ❌ | ✅ (exceptions) |
| Human-readable text | ✅ (toText) | ❌ | ✅ (parse.text) |
| Timezone support | ✅ (tzid) | ✅ (tz option) | ✅ (local/UTC) |
| Seconds precision | ❌ | ✅ (6-field) | ✅ |
| Built-in timer | ❌ | ❌ | ✅ (setInterval) |
| Composite schedules | ✅ (RRuleSet) | ❌ | ✅ |
| Calendar recurrences | ✅ (nth weekday) | ❌ | ✅ (limited) |
| TypeScript | ✅ | ✅ | ✅ (@breejs/later) |
| Weekly downloads | ~800K | ~10M | ~1M |
When to Use Each
Use rrule.js if:
- Need iCalendar RRULE standard compliance
- Building calendar or scheduling apps with complex recurrences
- Need "first Monday of every month" or "every other Tuesday" patterns
- Want exclusion dates and RRuleSet combinations
Use cron-parser if:
- Working with cron expressions (the universal scheduling format)
- Need to validate and parse cron syntax
- Building job schedulers or task runners
- Want the most widely adopted solution
Use later if:
- Want human-readable schedule definitions
- Need composite schedules with exceptions
- Want built-in setInterval/setTimeout for schedule execution
- Prefer a fluent API over string-based formats
Timezone Handling and DST Edge Cases
Timezone handling in recurrence rules is one of the most subtle and bug-prone areas of scheduling logic. All three libraries approach this problem differently, and understanding the edge cases matters for production scheduling systems. rrule.js uses UTC for all date calculations internally and only converts to local time when displaying occurrences. For timezone-aware recurrences, you pass a tzid option (e.g., 'America/New_York') and rrule will correctly handle Daylight Saving Time transitions — a rule for "every day at 9am ET" will produce 9am ET even during the spring-forward and fall-back transitions. cron-parser handles timezones through the tz option in parseExpression, and similarly uses the IANA timezone database for DST-aware calculations. The key edge case for cron expressions is the "spring forward" hour (2am to 3am when clocks jump): a cron job scheduled for 30 2 * * * in a DST timezone will either be skipped or run twice depending on the library and OS behavior. cron-parser handles this consistently by using UTC internally. later's later.date.localTime() mode uses the system's local timezone, which makes it simpler for applications running in a single timezone but harder to reason about for multi-timezone deployments.
Production Job Scheduling Architecture
In production, recurrence rule libraries are typically used in one of two patterns: calculating the next occurrence at runtime to decide when to run a job, or generating a list of future occurrences to pre-schedule work. For Node.js job schedulers like node-cron, bull, and BullMQ, the cron syntax is accepted directly, and cron-parser is used under the hood by many of these libraries to calculate next execution times. When building a custom scheduling system that persists schedules to a database, rrule.js is particularly valuable because the RRULE string format is a compact, standardized way to store complex recurrence definitions — you can save the RRULE string to a TEXT column and reconstruct the rule object on demand without needing a custom schema for every recurrence variant. later's programmatic schedule definitions don't serialize as compactly as RRULE strings and require either serializing the JavaScript object as JSON or re-constructing the schedule from stored text descriptions.
Calendar Application Integration with RRULE
The primary production use case for rrule.js is building calendar applications that need to follow the iCalendar RFC 5545 standard. Any system that imports or exports .ics files (Google Calendar, Apple Calendar, Outlook) uses RRULE syntax for recurring events. When your application needs to import iCalendar data and render recurring events on a calendar grid, rrule.js provides the rrulestr function that parses the full RRULE component including DTSTART and EXDATE lines from an ICS file. A common production challenge is generating occurrences for infinite rules (no UNTIL or COUNT) to display them on a bounded calendar view — rule.between(startDate, endDate) solves this by only generating occurrences within the display range, which prevents memory issues from generating all possible occurrences of a "every weekday forever" rule. For SaaS applications that allow users to define recurring events, storing the RRULE string in the database and reconstructing it via rrule.js on demand is the standard production pattern.
Performance Considerations for High-Volume Scheduling
Performance matters when recurrence calculations run in hot paths. cron-parser is the fastest of the three for simple next-occurrence calculations because cron expressions map to a small set of integer ranges that can be computed with bitwise operations. For a job scheduler that calculates the next run time for thousands of scheduled jobs every minute, cron-parser's throughput is adequate without optimization. rrule.js is slower for complex RRULE patterns because it generates occurrences sequentially through a date iteration algorithm, but for typical use cases (generating the next 30 occurrences for a calendar view), the computation is well under 1ms and not a meaningful bottleneck. later's built-in setInterval and setTimeout create Node.js timer objects that check schedule validity on each tick, which can create performance issues if many schedules are created simultaneously. For high-volume scheduling (hundreds of active schedules), a database-persisted approach with a single scheduler loop is more efficient than creating one JavaScript timer per schedule.
Community Ecosystem and Package Maintenance
rrule.js is actively maintained as of 2026, with the primary repository at github.com/jkbrzt/rrule and TypeScript types included. The @breejs/later package is a maintained fork of the original later.js by Bryce Embry, created because the original later package from npm was unmaintained for several years. Both @breejs/later and the original later packages coexist on npm, and care should be taken to use @breejs/later for actively maintained behavior. cron-parser is well-maintained with regular releases and comprehensive test coverage for edge cases including leap years, DST transitions, and seconds-precision expressions. For new projects choosing between these libraries, the decision should be driven primarily by the recurrence format your system needs to produce or consume: RRULE for calendar interoperability, cron for job scheduling integration with standard tools, and later for human-readable schedule definitions in administrative interfaces.
Serialization and Storage of Recurrence Rules
Persisting recurrence rules to a database requires deciding on a storage format, and the library choice affects this decision. RRULE strings are compact, self-describing, and universally parseable by any RFC 5545 compliant library across any language — storing FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=1 as a text column in PostgreSQL means your recurrence data is portable across systems and readable without the JavaScript library. cron expressions are equally compact but less portable across languages because the field interpretation varies between implementations. later's text schedules ("every 2 hours on weekdays") are not standardized and require later to parse them, creating a library lock-in at the storage layer. For APIs that accept recurrence input from clients, using RRULE as the canonical format and rrule.js as the parser decouples the storage format from the library while maintaining interoperability with calendar applications. Converting stored RRULE strings to cron expressions for execution by a job scheduler (like cron-parser or BullMQ's repeat options) is a common bridge pattern that combines RRULE's expressiveness as a storage format with cron-based job schedulers' execution reliability.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on rrule v2.x, cron-parser v4.x, and @breejs/later v4.x.
Compare scheduling libraries and backend tooling on PkgPulse →
See also: AVA vs Jest and Payload CMS vs Strapi vs Directus, amqplib vs KafkaJS vs Redis Streams.