Scheduling Background Jobs with Cron
How cron expressions work, how to reason about schedules without guessing, and how to run background jobs on Vercel, a VPS, or your own machine.
Scheduling Background Jobs with Cron
Cleanup scripts, digest emails, cache warm-ups, nightly reports — most projects eventually need something to run on a schedule instead of in response to a request. Cron is the standard way to express that schedule, and it's worth understanding the syntax properly instead of copy-pasting expressions you don't fully trust.
Anatomy of a Cron Expression
A cron expression is five fields, each narrowing down when the job fires:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday=0)
│ │ │ │ │
* * * * *
Each field can be a specific value, a wildcard (* = every value), a range (1-5), a list (1,3,5), or a step (*/15 = every 15 units).
0 3 * * * Every day at 3:00 AM
*/15 * * * * Every 15 minutes
0 9 * * 1-5 9:00 AM, Monday through Friday
0 0 1 * * Midnight on the 1st of every month
30 2 * * 0 2:30 AM every Sunday
The most common mistake is reasoning about the fields left to right as if they're nested ("every day, at hour 3, at minute 0") when they're actually independent constraints evaluated together — the job fires at the exact minute and hour and day-of-month and month and day-of-week that all match simultaneously. This matters once you combine day-of-month and day-of-week in the same expression, where most cron implementations treat it as an OR rather than an AND.
Don't Hand-Write Complex Expressions
For anything past "every N minutes" or "daily at a fixed time," write the schedule in English first, then verify the expression against a parser rather than trusting your first attempt — an off-by-one in the hour field silently runs your job at the wrong time for months. This site's Cron Builder does exactly that: describe the schedule, get the expression, and see the next few run times before you commit to it.
Where the Job Actually Runs
The expression only defines when — you still need something to trigger it. The right mechanism depends on your hosting model.
Vercel / Serverless
Vercel Cron Jobs hit an API route on a schedule you define in vercel.json:
{
"crons": [
{ "path": "/api/cron/cleanup", "schedule": "0 3 * * *" }
]
}
The route itself is a normal API handler — the only thing that makes it a cron job is Vercel calling it on schedule instead of a user's browser. Two things matter here that are easy to skip:
- Verify the request actually came from Vercel's scheduler, not an open endpoint anyone can hit. Vercel sends an
Authorization: Bearer <CRON_SECRET>header — check it against an env var before doing any work. - Respect the function timeout. Serverless crons are still serverless functions — if the job might run long, have it enqueue work rather than doing it all inline.
export async function GET(req: Request) {
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
// ... do the work
return new Response("ok");
}
A VPS or Your Own Machine
The crontab is the OS-level scheduler — no application code needed to trigger it:
crontab -e
0 3 * * * /usr/bin/node /home/user/app/scripts/cleanup.js >> /var/log/cleanup.log 2>&1
The >> ... 2>&1 at the end isn't optional in practice — without it, failures happen silently and you only find out when the thing the job was supposed to maintain has been broken for a week.
On Windows, the equivalent is Task Scheduler rather than cron directly — this project's own .bat startup scripts are launched this way rather than through a cron daemon, since Windows doesn't ship one.
In-Process (Node, No Separate Scheduler)
For a single small job where standing up infrastructure is overkill, a library like node-cron runs the schedule inside your existing process:
import cron from "node-cron";
cron.schedule("0 3 * * *", async () => {
await runCleanup();
});
This only works as long as the process stays alive continuously — it's a poor fit for serverless (the process doesn't persist between invocations) and a fine fit for a long-running server you already operate.
Idempotency Is Not Optional
Any job triggered on a schedule will eventually run twice for the same period — a retry after a timeout, a deploy that overlaps the old and new cron, a manual re-trigger while debugging. Design the job so running it twice for the same window produces the same result as running it once:
- A cleanup job should delete-if-exists, not assume the row is still there.
- A digest email should check "have I already sent today's digest" before sending, not just "is it 9 AM."
- A report generator should overwrite the same file/record for a given date, not append a duplicate.
This is the single most common source of "why did the user get two emails" bugs, and it's much cheaper to design against upfront than to patch after the fact.
Quick Reference
| Schedule | Expression |
|---|---|
| Every minute | * * * * * |
| Every 5 minutes | */5 * * * * |
| Every hour | 0 * * * * |
| Every day at midnight | 0 0 * * * |
| Every weekday at 9am | 0 9 * * 1-5 |
| Every Sunday at 2:30am | 30 2 * * 0 |
| First of every month | 0 0 1 * * |
When in doubt, build it in Cron Builder and read back the plain-English description before you deploy it — that's the cheapest sanity check available.
Stay in the flow
Get vibecoding tips, new tool announcements, and guides delivered to your inbox.
No spam, unsubscribe anytime.