[{"data":1,"prerenderedAt":29},["ShallowReactive",2],{"$fLQ5rkty9l_YOmVrD2kZwwIPd-5MH_EA1Zv7sAk_AvEE":3},{"slug":4,"title":5,"description":6,"date":7,"tags":8,"draft":13,"readingMinutes":14,"html":15,"related":16},"node-cron","node-cron in Production: Overlaps, Replicas and DST","node-cron 4 in Node.js, checked November 2026: noOverlap, running on one replica, the day-of-week AND rule, DST gaps, and when systemd or a queue is better.","2026-11-07",[9,10,11,12],"node","cron","scheduling","devops",false,4,"\u003Cp>node-cron is the package most Node apps reach for when they need &quot;run this every night at 3&quot;. You give it a cron expression and a function, and it calls the function on schedule inside your running process. Two lines, no server config. That&#39;s also its whole risk: the schedule only exists while that process is alive, and it exists once per process.\u003C/p>\n\u003Cp>Version 4 fixed most of what used to make it a bad idea in production. Versions as of November 7, 2026: \u003Ccode>node-cron\u003C/code> 4.6.0 (released July 5, needs Node 20 or newer), and \u003Ccode>croner\u003C/code> 11.0.0 if you want the alternative. Details below are from the \u003Ca href=\"https://github.com/node-cron/node-cron\">node-cron README\u003C/a> and its docs at \u003Ca href=\"https://nodecron.com\">nodecron.com\u003C/a>.\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-js\">import cron from &#39;node-cron&#39;;\n\ncron.schedule(&#39;0 3 * * *&#39;, runNightlyReport, {\n  name: &#39;nightly-report&#39;,\n  timezone: &#39;Europe/London&#39;,\n  noOverlap: true,\n});\n\u003C/code>\u003C/pre>\n\u003Ch2>The three bugs it can give you\u003C/h2>\n\u003Cp>\u003Cstrong>Overlap.\u003C/strong> A job scheduled every minute that sometimes takes 90 seconds will run twice at once. In v3 that was your problem; in v4 \u003Ccode>noOverlap: true\u003C/code> skips the next run instead of stacking it, and fires an \u003Ccode>execution:overlap\u003C/code> event so you can see it happened. I turn it on for every job. If a skipped run is a problem, the job is too slow for its schedule and that&#39;s the real bug.\u003C/p>\n\u003Cp>\u003Cstrong>Replicas.\u003C/strong> Deploy your API as three containers or three PM2 cluster workers and you have three schedulers. Every job runs three times. People find this when a customer gets three copies of the weekly invoice email. node-cron 4 has \u003Ccode>distributed: true\u003C/code>, and the default behaviour is blunt: it reads an environment variable called \u003Ccode>NODE_CRON_RUN\u003C/code>, which you set to \u003Ccode>&#39;true&#39;\u003C/code> on exactly one instance and \u003Ccode>&#39;false&#39;\u003C/code> on the rest. There&#39;s no default; if it&#39;s missing, scheduling throws at startup, which I actually like. The weakness is obvious though. If that one instance is down at 3:00, nothing runs anywhere.\u003C/p>\n\u003Cp>For real failover there&#39;s \u003Ccode>@node-cron/redis-coordinator\u003C/code>. Every instance competes for a Redis lock on each fire, the winner runs it, and if the winner crashes mid-run the lock still holds for \u003Ccode>distributedLease\u003C/code> (30 seconds by default) before anyone else can take it. Set the lease longer than the job takes, or a slow job and a second runner will overlap anyway.\u003C/p>\n\u003Cpre>\u003Ccode class=\"language-js\">import { createClient } from &#39;redis&#39;;\nimport { setRunCoordinator } from &#39;node-cron&#39;;\nimport { RedisLockCoordinator } from &#39;@node-cron/redis-coordinator&#39;;\n\nconst redis = createClient({ url: process.env.REDIS_URL });\nawait redis.connect();\nsetRunCoordinator(new RedisLockCoordinator(redis));\n\u003C/code>\u003C/pre>\n\u003Cp>\u003Cstrong>Daylight saving.\u003C/strong> Schedules match wall-clock time in the task&#39;s timezone. On the night the clocks go back, the repeated hour runs once, so a \u003Ccode>*/15\u003C/code> job can go quiet for up to an hour. In spring it&#39;s worse for one job in particular: a daily 2:30 job in a zone that jumps from 2:00 to 3:00 doesn&#39;t run that day at all. The docs say node-cron skips times that don&#39;t exist rather than guessing, and picks up again at 2:30 the next day. If a job needs a steady interval, schedule it in \u003Ccode>UTC\u003C/code> and convert in your head. If it needs to run &quot;at 9 in the morning for the user&quot;, keep the user&#39;s zone and accept the two weird nights a year.\u003C/p>\n\u003Cp>\u003Cimg src=\"/blog/node-cron/node-cron-three-replicas.webp\" alt=\"Three app replicas each running node-cron: without coordination the 3:00 job fires three times; with NODE_CRON_RUN only one fires; with the Redis coordinator the replicas race for a lock and one wins\">\u003C/p>\n\u003Ch2>The cron syntax trap\u003C/h2>\n\u003Cp>node-cron accepts an optional seconds field in front, so six fields means seconds, not a year. \u003Ccode>0 */5 * * * *\u003C/code> is every five minutes at second zero, and \u003Ccode>*/5 * * * * *\u003C/code> is every five seconds, which is a fun one to ship by accident.\u003C/p>\n\u003Cp>The quieter trap is what happens when you set both day of month and day of week. Classic Unix cron treats them as OR: \u003Ccode>0 9 1 * 1\u003C/code> runs on the 1st of the month and on every Monday. node-cron&#39;s README says it combines them with AND: both must match, so the same line runs only when the 1st falls on a Monday. If you copy expressions out of an old crontab, check that one. It also adds Quartz-style extras (\u003Ccode>L\u003C/code> for the last day, \u003Ccode>15W\u003C/code>, \u003Ccode>2#3\u003C/code> for the third Tuesday) but keeps standard weekday numbers, so \u003Ccode>1\u003C/code> is Monday, not Sunday as in Quartz.\u003C/p>\n\u003Ch2>When I wouldn&#39;t use it at all\u003C/h2>\n\u003Cp>node-cron doesn&#39;t persist anything. If the process is restarting at 3:00, the 3:00 run is simply gone, and nothing will tell you unless you listen for \u003Ccode>execution:missed\u003C/code>. That&#39;s fine for &quot;clean up expired sessions&quot;, where the next run catches up. It&#39;s not fine for &quot;charge annual renewals&quot;.\u003C/p>\n\u003Cp>For anything that must happen, I&#39;d put a queue under it. The schedule (node-cron, or the queue&#39;s own repeatable jobs) only adds a job to a queue like \u003Ca href=\"/blog/bullmq\">BullMQ\u003C/a>, and the queue gives you retries, a record of what failed, and workers you can scale without three copies of the schedule. On a single Linux server, a \u003Ca href=\"https://www.freedesktop.org/software/systemd/man/latest/systemd.timer.html\">systemd timer\u003C/a> with \u003Ccode>Persistent=true\u003C/code> is the other honest option: it runs a missed job at the next boot, logs every run to the journal, and doesn&#39;t care whether your Node process is healthy. That&#39;s where I&#39;d put backups and certificate checks, things that should work even when the app is broken.\u003C/p>\n\u003Cp>\u003Ccode>croner\u003C/code> is worth a look if you also run on Deno or Bun, or want a scheduler that tells you the next ten run dates for a pattern before you deploy it. For a plain Node service, node-cron 4 with \u003Ccode>noOverlap\u003C/code> and a coordinator is good enough now, and I don&#39;t say that about many cron libraries.\u003C/p>\n\u003Cp>If your scheduled jobs are running twice, not at all, or at the wrong hour, and you&#39;d like someone to sort it out properly, \u003Ca href=\"https://calendly.com/director-norsky/meeting\">book a free call\u003C/a>. All consultation is free.\u003C/p>\n",[17,21,25],{"slug":18,"title":19,"description":20},"redis-streams","Redis Streams in Node: Consumer Groups Without the Surprises","Redis Streams with consumer groups in Node.js, checked November 2026: the worker loop, the pending list, claiming stuck messages, trimming, and Redis 8's new commands.",{"slug":22,"title":23,"description":24},"nginx-vs-caddy","Nginx vs Caddy: Which Reverse Proxy I'd Pick in 2026","Nginx vs Caddy for a Node, Laravel or Nuxt app on one server: HTTPS, WebSockets, headers, the new nginx ACME module, and when each one wins.",{"slug":26,"title":27,"description":28},"pgbouncer","PgBouncer: What Transaction Mode Breaks, and the Fix","PgBouncer in transaction mode lets 1,000 clients share 20 Postgres connections. What it breaks (SET, LISTEN, advisory locks), and what 1.24 fixed.",1794095794438]