npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@visulima/cron-parser

v2.0.0

Published

Fast, zero-dependency cron expression parser with time zone and DST support, for Node.js, Bun and Workers

Downloads

46,076

Readme

typescript-image mit licence npm downloads Chat PRs Welcome



Install

npm install @visulima/cron-parser
yarn add @visulima/cron-parser
pnpm add @visulima/cron-parser

Usage

import { parse } from "@visulima/cron-parser";

const interval = parse("0 9 * * 1-5", {
    currentDate: "2026-03-06T12:00:00Z",
    tz: "America/New_York",
});

interval.next().toISOString(); // "2026-03-06T14:00:00.000Z"  (09:00 EST)
interval.next().toISOString(); // "2026-03-09T13:00:00.000Z"  (09:00 EDT, after the DST change)
interval.prev().toISOString(); // "2026-03-06T14:00:00.000Z"

next() and prev() move a cursor, so they can be mixed. take(n) returns the next n runs (a negative n walks backwards), and the interval is iterable:

const days = parse("0 0 * * *", {
    currentDate: "2026-01-01T00:00:00Z",
    endDate: "2026-01-03T12:00:00Z",
    tz: "UTC",
});

[...days].map((date) => date.toISOString());
// ["2026-01-02T00:00:00.000Z", "2026-01-03T00:00:00.000Z"]

Options

| Option | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | currentDate | Where iteration starts. A Date, epoch milliseconds, a date string or a CronDate. Defaults to now. | | startDate | Earliest allowed run. Used as currentDate when none is given. | | endDate | Latest allowed run. next() throws and iteration ends once it is passed. | | tz | IANA time zone the expression is read in, such as Europe/Berlin. Defaults to the system zone. | | hashSeed | Seed for H (hashed) values, so the same seed always picks the same minute and hour. | | strict | Reject an expression that sets both day of month and day of week, is shorter than the field count the grammar defines (five, or six with seconds), or has an unsatisfiable H range. | | dialect | Hold the expression to another runtime's grammar: extended (the default), cron-parser or cloudflare. See Dialects. | | features | Turn a single switch on or off on top of dialect, such as { hashed: false }. | | seconds | Whether a sixth, seconds field is "forbid", "optional" (the default) or "require". Judged on the expression as written, so @daily has no seconds field. |

Expression syntax

Five or six fields (seconds are optional and come first): second minute hour day-of-month month day-of-week.

  • *, ?, lists (1,15), ranges (1-5) and steps (*/10, 10-30/5)
  • Month names (JAN-DEC) and weekday names (SUN-SAT); 0 and 7 are both Sunday
  • L for the last day of the month (0 0 L * *) and the last given weekday (0 9 * * 5L)
  • In the day of month field, from Quartz: 15W (the weekday nearest the 15th, without leaving the month), LW (the last weekday of the month) and L-3 (three days before the last day). See Beyond the reference implementation.
  • # for the nth weekday of the month (0 9 * * 2#2 is the second Tuesday)
  • H for a hashed value: H, H(0-29), H/15
  • @yearly, @annually, @monthly, @weekly, @daily, @hourly, @minutely, @secondly, @weekdays, @weekends

When both day of month and day of week are set, a day matching either one runs, as in Vixie cron.

Dialects

Every runtime reads a slightly different cron. dialect holds an expression to what a target's own parser accepts, and features turns a single switch on or off when the target is stricter than the named dialect.

| Dialect | What it keeps | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | extended | Everything on this page. The default. | | cron-parser | The grammar of cron-parser v5: H, #, L and @nicknames, but not the W, LW or L-n day of month extensions. | | cloudflare | Cloudflare Workers Cron Triggers: five fields, L, W, LW, L-n and # (with names), but not H, ?, @nicknames, a seconds field, a day of month 5L or a day of month text in a list. |

parse("H * * * *", { dialect: "cloudflare" });
// throws CronParseError: Unsupported feature "hashed" in the selected dialect

parse("0 0 1W * *", { dialect: "cron-parser", features: { nearestWeekday: true } });
// parses: the dialect turns `1W` off, the feature turns it back on

features holds only the syntax an expression can be made of: hashed, lastDayOfMonth, lastDayOfWeek, lastOffset, lastWeekday, nearestWeekday, nicknames, noSpecificTime, nthDayOfWeek, numberedLastDayOfMonth. Seconds are not a switch: they have their own seconds option. How a target reads an expression — Cloudflare's weekday numbering, its step and list rules — belongs to the dialect rather than to features, so it is not a switch.

To read what an expression is actually made of, rather than constrain it, usedFeatures holds the extensions it uses:

parse("0 0 LW * *").usedFeatures; // Set { "lastWeekday" }
parse("0 0 0 * * *").usedFeatures; // Set { "seconds" }
parse("* * * * *").usedFeatures.has("hashed"); // false

The cloudflare dialect was checked against the validator wrangler itself deploys through, cron-triggers.cloudflareworkers.com (backed by cloudflare/saffron). A few of its rules are easy to miss and are built in: the weekdays run 1 (Sunday) to 7 (Saturday) and a weekday of 0 is rejected, so 0 0 * * 1 fires on Sunday and 0 18 * * 6L on the last Friday (the number before an L or a # follows the same scale; the three letter names always mean the day they name, and a bare L in the weekday field is Saturday); a day of month text such as LW, L-3 or 15W has to stand alone, so 1,15 parses but 1W,LW does not; and a step may not be as large as the field, so */6 is a valid weekday step but */7 is not, and minutes allow */59 but not */60.

On Cloudflare Workers

A schedule that is a Cloudflare Cron Trigger can take the entry that has the dialect already applied, so there is no dialect name to remember:

import { parse } from "@visulima/cron-parser/cloudflare";

parse("0 0 * * 1").next(); // Sunday, not Monday
parse("H * * * *"); // throws: Cloudflare has no hashed values

It is the default entry with { dialect: "cloudflare" } baked in, and a dialect you pass yourself still wins. It imports no node:* module, so it loads on Workers like the default entry does.

Parse errors

A parse failure throws a CronParseError, an Error with a code (such as FIELD_RANGE, FIELD_CHARACTER or UNSUPPORTED_FEATURE), the field it is in and the token that failed, so a caller can mark the spot without matching the message.

try {
    parse("10 ! 12 8 0");
} catch (error) {
    if (error instanceof CronParseError) {
        error.code; // "FIELD_CHARACTER"
        error.field; // "hour"
        error.token; // "!"
    }
}

More on an interval

interval.hasNext(); // is there another run before endDate?
interval.includesDate(new Date("2026-03-09T13:00:00Z")); // true
interval.reset(new Date("2026-06-01T00:00:00Z")); // move the cursor
interval.stringify(); // "0 9 * * 1-5"
interval.fields.hour.values; // [9]

Crontab files

Reading a crontab file needs node:fs, so it lives in its own entry point. The main entry never imports it, which keeps it loadable on Workers.

import { parseFile, parseFileSync } from "@visulima/cron-parser/file";

const { expressions, variables, errors } = await parseFile("./crontab");

Time zones and DST

An expression is read on the wall clock of its tz, and a run is a real instant whose wall clock time matches it. Around a clock change that gives these rules:

  • A time that does not exist (02:30 on the spring-forward day) runs after the gap, at the same minute of the first hour after it, when the expression names its hours. An every-hour schedule just skips the missing hour, so it does not run twice. A day that never happened at all (Samoa lost 30 December 2011) is dropped rather than moved.

    parse("30 2 * * *", { currentDate: "2021-03-13T12:00:00Z", tz: "America/New_York" }).next().toISOString();
    // "2021-03-14T07:30:00.000Z"  (03:30 EDT)
  • A time that happens twice (01:30 on the fall-back day) runs once, at the first pass, when the expression names its hours, and at both passes for an every-hour schedule. next(), prev() and includesDate() all follow the same rule.

  • Zones with 30 and 45 minute offsets, gaps that begin mid-hour and gaps of more than an hour are handled.

next() and prev() do not step through DST. They list the wall clock times that match, with plain calendar arithmetic that skips whole months and days, and then turn each into the real instants it happens at. A schedule such as the fifth Wednesday of February is found directly, however far away it is.

Everything zone related comes from one primitive, a zone's UTC offset at an instant. It comes from native Temporal where the runtime has it (Bun does today) and from Intl.DateTimeFormat everywhere else. Both give the same answers: the test suite runs against each. The DST rules are checked against an independent brute-force model of the clock (it scans a zone minute by minute and inverts it) in 14 zone and date windows, including Samoa's lost day, Troll's two hour jump, Chatham's mid-hour gap and a 1844 Manila offset with seconds in it. Away from clock changes the results are compared with the cron-parser package and have to be identical. Native Temporal is also the faster backend where it exists: about 2 times the throughput of Intl on Bun.

CronDate is the date type next() and prev() return. It reads and moves a point in time on one zone's wall clock, and also exposes getUTCOffset(), toISOString() (UTC) and toJSON() (with the zone's own offset):

import { CronDate } from "@visulima/cron-parser";

const date = new CronDate("2026-07-01T12:00:00Z", "Europe/Berlin");

date.getHours(); // 14
date.getUTCOffset(); // 120
date.toJSON(); // "2026-07-01T14:00:00.000+02:00"

Date strings may be ISO 8601 / SQL, RFC 2822 or HTTP dates. A string without an offset is wall clock time in tz.

Runtimes and size

The main entry uses only Intl, optionally Temporal, and plain JavaScript, so it runs unchanged on Node.js, Bun and Cloudflare Workers. @visulima/cron-parser/cloudflare is the same entry with the Cloudflare dialect applied, and @visulima/cron-parser/file is the one that needs node:fs. It is ESM only, side-effect free, and has no dependencies.

Bundled and minified, parse(...).next() is about 31 KB (11 KB gzipped), and a bare CronDate is about 10 KB (4 KB gzipped). The same import through cron-parser and luxon is about 100 KB (31 KB gzipped). pnpm run build:prod && pnpm run size prints the figures for this package. Iteration is also faster than that package; __bench__ compares it with croner, cron-schedule and cron-fast too.

Tree-shaking helps less than the sizes suggest: parsing needs the whole expression engine, so importing parse costs nearly the same as importing everything. What it does keep out of your bundle is the crontab reader (a separate entry point) and, if you only need dates, the parser.

Coming from cron-parser

The API is the same shape: CronExpression, CronFieldCollection, CronDate and the field classes keep their names and behaviour. Upgrading from 1.0.0 is covered in the migration guide. What changes:

  • ESM only, no luxon. Timezone handling is built in.
  • CronExpressionParser.parse(expr, options) becomes the function parse(expr, options); there is no parser class or default export.
  • CronFileParser becomes the functions parseFile and parseFileSync, from @visulima/cron-parser/file.
  • TimeUnit, DateMathOp, CronUnit and the other enums are as const objects, so they tree-shake.
  • A failed parse throws a CronParseError carrying a code, the field and the token, rather than a plain Error.
  • dialect and features hold an expression to another runtime's grammar; see Dialects.
  • A day of month 5L is read as L, the last day, the way cron-parser reads it. The cloudflare dialect turns this off, because Cloudflare rejects it.
  • new CronDate(0) is the epoch. (Before, any falsy value meant "now".) An empty string throws.
  • toISOString() and toJSON() return string, never null.
  • An unknown tz throws CronDate: unhandled timezone: <name>.
  • CronDate reads ISO 8601 / SQL, RFC 2822 and HTTP date strings. Other luxon-only forms (ISO week and ordinal dates, UTC+3 style zone names) are not accepted.

Behaviour that differs

These follow from the DST rules above and from defects in cron-parser that this package fixes. A schedule that never crosses a clock change is unaffected.

  • prev() after a spring-forward gap returns the run that was moved after the gap instead of skipping back a day.
  • A repeated hour is run once going backwards and in includesDate() as well as going forwards, and the result no longer depends on whether the search starts in the first or the second pass.
  • A fall-back of less than an hour (Australia/Lord_Howe) and a gap that begins mid-hour (Pacific/Chatham) no longer make next() or prev() fail with Invalid expression, loop limit exceeded.
  • A schedule with no run for decades (the fifth Wednesday of February) no longer hits that limit.
  • CronDate no longer has dstStart, dstStartLandingHour, dstEnd, clearDstStart, applyDateOperation, invokeDateOperation or DateMathOp: they existed to drive the reference implementation's search and always reported nothing on a returned date. DAYS_IN_MONTH is no longer exported from the date module.
  • includesDate() asks only whether the date is one of the schedule's runs; it ignores startDate and endDate.
  • startDate and endDate are both inclusive: a run exactly on startDate is the first one returned, and one exactly on endDate the last. A currentDate is still excluded, so next() never returns the cursor.
  • reset() without an argument goes back to the starting point, which is startDate when there is no currentDate.
  • A crontab line is a variable only when it looks like NAME=value; a command that happens to contain = stays a job. @daily-style schedules in a crontab file are read, and @reboot is reported in errors.

The two crontab changes and reset() are not tied to DST; the rest are.

Beyond the reference implementation

These are in this package and not in cron-parser. All three are day of month atoms, usable on their own or in a list (1,15W,LW), and none of them can be combined with a range or a step.

| Syntax | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 15W | The weekday (Monday to Friday) nearest the 15th, without leaving the month. A Saturday gives the Friday before, a Sunday the Monday after. The 1st on a Saturday gives Monday the 3rd, and the last day on a Sunday gives the Friday before. A month shorter than the day (31W in April) has no run. | | LW | The last weekday of the month. | | L-3 | Three days before the last day of the month. L-1 to L-30 are accepted; a month too short for the offset has no run. |

parse("0 0 15W * *", { currentDate: "2026-01-31T00:00:00Z", tz: "UTC" })
    .take(3)
    .map((run) => run.toISOString().slice(0, 10));
// ["2026-02-16", "2026-03-16", "2026-04-15"]   (15 February and March 2026 are Sundays)

When the day of week field is also set, a day matching either field runs, as with any other day of month value.

Open requests this does not cover

A "this run" accessor and shorter stringify() output for stepped ranges are requests upstream has not implemented and this package does not either. Switching the non-standard extensions off is covered by the dialect and features options above.

The temporal-polyfill package has wrong offsets for Africa/Casablanca (Morocco's negative DST); native Temporal in Bun agrees with Intl there. The DST model above does not depend on the polyfill's data, but the comparison with cron-parser skips that zone when running on the polyfill.

Supported Node.js Versions

Libraries in this ecosystem make the best effort to track Node.js' release schedule. Here's a post on why we think this is important.

Contributing

If you would like to help take a look at the list of issues and check our Contributing guild.

Note: please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.

Credits

License

The visulima cron-parser is open-sourced software licensed under the MIT