Node.js powers every-minute Slack digests, hourly Stripe webhook reconciliation, nightly database snapshots to S3. Four credible ways to fire them: drop a script in crontab and run node, run an in-process library like node-cron or BullMQ, use a platform scheduler (Vercel Cron, Cloudflare Workers, AWS Lambda + EventBridge), or let an external cron service like Crontap hit an HTTP endpoint on cadence.
Side-by-side walkthrough with code, for Node.js 22 LTS and 24 (current as of 2026). For Linux cron basics, see What is a cron job in Linux?.
Path 1: crontab + node
The classic. A .js or .mjs file on disk, OS cron firing node.
# crontab -e
*/5 * * * * /usr/local/bin/node --enable-source-maps /opt/app/dist/digest.js >> /var/log/digest.log 2>&1Three traps eat most Node teams:
- Wrong
nodepath. Cron'sPATHis tiny. Hardcode the absolute path fromwhich node./usr/bin/nodeis fine on plain Ubuntu, on most dev boxes it's/home/deploy/.nvm/versions/node/v22.11.0/bin/node. - nvm not in cron's PATH. The #1 Node cron failure. nvm only loads in interactive shells, so
node: command not foundshows up even though the binary is there. Symlink to/usr/local/bin/nodeor source nvm in a wrapper script. - ES modules vs CommonJS. Use
.mjs(or"type": "module"inpackage.json) for top-levelawait. Pass--enable-source-mapsso stack traces map back to your TypeScript.
Pick this when: one VM, one team, SSH access, stable Node version.
Path 2: In-process schedulers
These run inside a long-lived Node process. The tradeoff is always-on cost. You pay for a container or VM 24/7 even when nothing is firing.
node-cron. The simple one. Cron syntax, in-memory, no persistence. Great for a single instance, dies with the process.cron(the npm package). More featureful, per-job timezones, start/stop control. Still in-memory.- BullMQ repeatable jobs. Redis-backed and durable. Retries, backoff, dead-letter, distributed locking for free. The right answer once you have multiple workers.
- Agenda. MongoDB-backed, similar shape to BullMQ for shops already on Mongo.
import cron from "node-cron";
cron.schedule("*/5 * * * *", async () => {
await sendSlackDigest();
});Pick this when: you already run an always-on worker and want scheduling colocated. Avoid when: the process can die or scale to zero, or you run more than one replica (see clustering trap below).
Path 3: Platform cron
Cloud platforms ship cron-shaped triggers:
- Vercel Cron. Schedules in
vercel.json. Hobby is hourly floor, Pro is minute floor. See Vercel cron alternatives and why teams move off Vercel Cron. - Cloudflare Workers Cron Triggers. Edge cron, up to 3 triggers per Worker. See Cloudflare Workers cron alternatives and cron jobs for Cloudflare Workers.
- AWS Lambda + EventBridge. Most powerful, most IAM-heavy. See cron jobs for AWS Lambda.
- PM2 cron restart. Self-hosting?
--cron-restart "0 4 * * *"cycles a worker nightly, a poor man's scheduler for long-running tasks.
Pick this when: the workload already lives on that platform. Watch for: cold starts, timezone quirks, 10-second to 5-minute execution caps.
Path 4: External HTTP cron
Schedule your Node jobs from outside the box. Free forever tier with one schedule. Try Crontap →
Deploy an Express, Fastify, or Hono endpoint behind a bearer header, then point an external cron service at it.
import express from "express";
const app = express();
app.post("/internal/slack-digest", async (req, res) => {
if (req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) {
return res.sendStatus(401);
}
await sendSlackDigest();
res.sendStatus(204);
});In Crontap: POST https://api.yourapp.com/internal/slack-digest, header Authorization: Bearer <secret>, cron */5 * * * *, timezone America/New_York. Pro is $3.25/mo annual flat for unlimited HTTP schedules at minute cadence on a 1-minute floor.
The clustering trap
The bug that pushes most Node teams to external cron. Run any in-process scheduler (node-cron, cron, naive setInterval) and cluster the app, with PM2 cluster mode, two Kubernetes replicas, multi-region Fly deploys, every replica fires every job. Your every-5-minutes Slack digest goes out 4 times. Stripe reconciliation double-charges.
Two fixes:
- Pick-one-leader. Wrap the scheduler in a Redis lock with
redlock. Only the leader runs the work. Fragile under network partitions, fine for low-stakes jobs. - Move the schedule layer out of the app. BullMQ (one queue, N workers) or external HTTP cron (one fire, N replicas behind a load balancer).
Once you have more than one replica, in-process scheduling isn't free.
FAQ
Should I use node-cron or BullMQ?
node-cron for one process, no persistence. BullMQ the moment you need retries, multiple workers, or a job that survives a restart.
Why does my cron fire 5 times in production?
You clustered. See above. Fix with a Redis leader lock or move the schedule out of the app.
What about Deno or Bun?
Deno ships Deno.cron baked in (durable on Deno Deploy). Bun runs setInterval and works with node-cron. The four paths above apply, just swap the runtime.
How do I keep the schedule running when my container scales to zero?
You can't. Pin one worker to a minimum instance of 1, or use platform cron or external HTTP cron, so the schedule lives outside the container.
Related on Crontap
- Cron troubleshooting hub. Full debug index.
- Cron job monitoring. Failure alerts and dead-man pings.
- Cron job not running?. The "fires locally, silent in prod" checklist.
- Cron jobs for AWS Lambda. Path 3, deep dive.
- Cron jobs for Cloudflare Workers. Path 3 at the edge.
