I came to backend development from the frontend. On the frontend, everything happens because a user did something: a click, a scroll, a form submit. The first time I set up a cron job, something clicked for me. Here was code that ran because time passed. Nobody had to visit the site. Nobody had to press a button. The server just woke up at 2:00 AM and did its job.
I've used cron jobs a lot since then, and I genuinely love the concept. My favorite use by far is automated reports. I have jobs that put together daily, weekly, and monthly reports and send them straight to my email. I don't open a dashboard or run a query. The numbers are just in my inbox when I wake up, every day, every Monday, and on the first of every month. I also use cron to run other tasks in the background so I don't have to remember them.
This guide is everything I wish I'd had in one place: what cron is, how to read the syntax without guessing, how to run scheduled jobs in TypeScript, how I build my email reports, where to host them, and the mistakes that will bite you if you don't plan for them.
What I'll cover in this guide
- What is a cron job? Where cron came from and what "cron job" means today, from the Unix daemon to platform schedulers.
- What cron jobs are actually used for Cleanups, reminders, data syncing, reports, backups and health checks, plus the jobs I run myself.
- Cron syntax: the five fields How to read any cron expression, the special characters, a cheat sheet of common schedules, and two syntax traps.
- Your first cron job on a Linux server Using
crontab -e, full paths, and logging output so you can debug failures. - Cron jobs in Node.js with TypeScript Scheduling with
node-cronandcroner, keeping job logic testable, and the limits of in-app schedulers. - My favorite use: daily, weekly and monthly email reports A full walkthrough: working out the date range, querying the numbers, sending with Nodemailer, scheduling all three, and never sending a report twice.
- Cron jobs on hosting platforms Vercel Cron Jobs, GitHub Actions, Render, and cPanel on shared hosting (with my two go-to patterns and the Cron Email trick), plus how to choose.
- The mistakes that will bite you Time zones, daylight saving, overlapping runs, jobs that aren't safe to repeat, silent failures, oversized runs, and testing only in production.
- A production checklist What I check before shipping any scheduled job.
- Wrapping up and FAQ Where to start if you're new to backend, and quick answers to common cron questions.
What is a cron job?
A cron job is a task that runs automatically on a schedule. The name comes from cron, a Unix program (a "daemon") that has been running in the background of Linux and macOS machines since the 1970s. The word comes from chronos, Greek for time.
Cron reads a list of schedules from a file called a crontab ("cron table"). Each line says when to run something and what to run. Every minute, cron checks the list and runs anything that's due.
Today, "cron job" means more than the Unix program. People use it for any scheduled task, whether it runs through:
- the actual
crondaemon on a Linux server, - a library inside your Node.js app,
- a platform feature like Vercel Cron Jobs, Render Cron Jobs, or GitHub Actions schedules,
- a queue system like BullMQ with repeatable jobs.
They all share the same idea, and almost all of them use the same five-field syntax.
What are cron jobs actually used for?
Here are the kinds of jobs I see (and write) most:
- Cleanup: delete expired sessions, old password reset tokens, unverified accounts, temp files.
- Reminders and notifications: send appointment reminders the day before, weekly digests, "your trial ends tomorrow" emails.
- Data syncing: pull prices, exchange rates, or RSS feeds from an external API every hour.
- Reports: build a daily sales summary and email it at 8 AM.
- Backups: dump the database every night and upload it to storage.
- Health checks: ping your own API and alert you when it's down.
- Billing: renew subscriptions, retry failed payments.
- Cache warming and rebuilds: regenerate a sitemap, refresh a leaderboard, rebuild search indexes.
For me, reports come first. A daily summary, a weekly roundup, and a monthly overview, all landing in my email without me lifting a finger. I'll show you exactly how I build that further down.
Beyond reports, my cron jobs back up my database every night, back up uploaded media every Sunday, check every 5 minutes that my APIs are up, send an unread-messages reminder each morning, and run monthly maintenance on the 1st.
Cron syntax: the five fields
This is the part that looks scary and turns out to be simple. A standard cron expression has five fields, separated by spaces:
┌───────────── minute (0–59)
│ ┌─────────── hour (0–23)
│ │ ┌───────── day of month (1–31)
│ │ │ ┌─────── month (1–12 or JAN–DEC)
│ │ │ │ ┌───── day of week (0–6, Sunday = 0; 7 is also Sunday on most systems; or SUN–SAT)
│ │ │ │ │
* * * * * command to run
Read it left to right as: "At this minute, of this hour, on this day of the month, in this month, on this day of the week."
The special characters
| Character | Meaning | Example | Reads as |
|---|---|---|---|
* |
every value | * * * * * |
every minute |
, |
a list | 0 9,17 * * * |
at 9:00 and 17:00 |
- |
a range | 0 9 * * 1-5 |
9:00, Monday to Friday |
/ |
a step | */15 * * * * |
every 15 minutes |
You can combine them: 0 8-18/2 * * 1-5 means "on the hour, every 2 hours from 8:00 to 18:00, Monday to Friday."
Cheat sheet of common schedules
| Expression | What it does |
|---|---|
* * * * * |
Every minute |
*/5 * * * * |
Every 5 minutes |
0 * * * * |
Every hour, on the hour |
0 */6 * * * |
Every 6 hours |
0 0 * * * |
Every day at midnight |
30 2 * * * |
Every day at 2:30 AM |
0 9 * * 1-5 |
Weekdays at 9:00 AM |
0 0 * * 0 |
Every Sunday at midnight |
0 0 1 * * |
First day of every month at midnight |
0 0 1 1 * |
Once a year, January 1st |
15 14 1 * * |
14:15 on the 1st of every month |
Many systems also accept shortcuts: @hourly, @daily (same as 0 0 * * *), @weekly, @monthly, @yearly, and @reboot (run once when the machine starts).
Two gotchas in the syntax
- Day-of-month and day-of-week are OR'ed, not AND'ed. If you set both, classic cron runs the job when either matches.
0 0 13 * 5does not mean "Friday the 13th." It means "every 13th of the month, and every Friday." If you need "Friday the 13th," schedule it for Fridays and check the date inside your code. - Some tools add a sixth field for seconds. Libraries like
node-cronandcroneraccept an optional seconds field at the front:*/10 * * * * *is every 10 seconds. Quartz (Java) and AWS EventBridge use their own variations. Always check which flavor your tool expects.
My tip: never trust yourself to read a cron expression at 1 AM. Paste it into crontab.guru and read the plain-English version before you ship.
Your first real cron job, on a Linux server
If you have a VPS (DigitalOcean, Hetzner, a Linode, anything with SSH), you already have cron.
Open your user's crontab:
crontab -e
Add a line:
# Run the cleanup script every night at 2:30 AM, and log the output
30 2 * * * cd /home/dara/app && /usr/bin/node dist/jobs/cleanup.js >> /home/dara/logs/cleanup.log 2>&1
Save and exit. Check it's there:
crontab -l
A few things in that line matter more than they look:
- Full paths. Cron runs with a tiny environment.
nodemight be on yourPATHin your terminal and not exist for cron. Usewhich nodeto find the full path. If you usenvm, this one will catch you. cdfirst. Cron doesn't start in your project folder, so relative paths and.envfiles won't be found unless you move there.>> file 2>&1. This appends both normal output and errors to a log. Without it, the output disappears (or gets emailed to a local mailbox nobody reads), and when the job fails you'll have no idea why.
Cron jobs in Node.js with TypeScript
Sometimes you don't want a separate crontab. You want the schedule to live in your code, next to your app, in the same repo, with types. That's where scheduler libraries come in.
Option 1: node-cron (the simplest)
npm install node-cron
npm install -D @types/node-cron
// src/jobs/index.ts
import cron from "node-cron";
import { deleteExpiredSessions } from "./cleanup";
// Every day at 02:30, Lagos time
cron.schedule(
"30 2 * * *",
async () => {
try {
const deleted = await deleteExpiredSessions();
console.log(`[cleanup] removed ${deleted} expired sessions`);
} catch (err) {
console.error("[cleanup] failed", err);
}
},
{ timezone: "Africa/Lagos" }
);
Import that file once when your server starts and the job runs as long as the process is alive.
Notice the try/catch. An unhandled rejection inside a scheduled callback is easy to miss, and in some setups it can crash the whole process. Treat every job like an API route: catch, log, move on.
Option 2: croner (my pick for TypeScript projects)
croner is written in TypeScript, has zero dependencies, supports time zones, and has a built-in way to stop a job from overlapping with itself.
npm install croner
import { Cron } from "croner";
import { sendAppointmentReminders } from "./reminders";
const reminders = new Cron(
"0 18 * * *", // every day at 18:00
{
name: "appointment-reminders",
timezone: "Africa/Lagos",
protect: true, // skip a run if the previous one is still going
},
async () => {
const count = await sendAppointmentReminders();
console.log(`[reminders] sent ${count} reminders`);
}
);
console.log("Next reminder run:", reminders.nextRun()?.toISOString());
nextRun() is great for sanity checks. Print it at startup and you'll know immediately if your expression or time zone is wrong.
Writing the job itself
Keep the schedule and the logic separate. The job function should be a normal async function you can call from a test, a script, or an admin button:
// src/jobs/cleanup.ts
import { db } from "../db";
import { sessions } from "../db/schema";
import { lt } from "drizzle-orm";
export async function deleteExpiredSessions(now = new Date()): Promise<number> {
const result = await db
.delete(sessions)
.where(lt(sessions.expiresAt, now))
.returning({ id: sessions.id });
return result.length;
}
Passing now in as a parameter makes the function trivial to test. You don't have to fake the clock to check that "sessions older than now get deleted."
My favorite use: daily, weekly, and monthly reports in my inbox
This is the setup I love most, so let's build it properly. The goal: three emails, on three schedules, each summarizing the period that just ended.
| Report | Schedule | Covers |
|---|---|---|
| Daily | 0 7 * * * (every day at 07:00) |
yesterday |
| Weekly | 0 7 * * 1 (Mondays at 07:00) |
last Monday to Sunday |
| Monthly | 0 7 1 * * (the 1st at 07:00) |
last calendar month |
The key idea: one report function, three schedules. The only thing that changes is the time window.
Step 1: work out the period that just ended
// src/reports/period.ts
export type ReportKind = "daily" | "weekly" | "monthly";
export function previousPeriod(kind: ReportKind, now = new Date()) {
// Midnight today (UTC). Swap in a time zone library if your business day isn't UTC.
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
if (kind === "daily") {
const start = new Date(today);
start.setUTCDate(start.getUTCDate() - 1);
return { start, end: today };
}
if (kind === "weekly") {
const daysSinceMonday = (today.getUTCDay() + 6) % 7; // Monday = 0
const end = new Date(today);
end.setUTCDate(end.getUTCDate() - daysSinceMonday); // this Monday
const start = new Date(end);
start.setUTCDate(start.getUTCDate() - 7); // last Monday
return { start, end };
}
const end = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1));
const start = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth() - 1, 1));
return { start, end };
}
The window is always "start included, end excluded" (>= start and < end). That way no record is counted twice or missed at midnight.
Calculating the window from the date, instead of "the last 24 hours", matters. If the job runs 3 minutes late, "last 24 hours" quietly shifts. "Yesterday" is always yesterday.
Step 2: gather the numbers
// src/reports/build.ts
import { and, gte, lt, count, sum } from "drizzle-orm";
import { db } from "../db";
import { orders, users } from "../db/schema";
import { previousPeriod, type ReportKind } from "./period";
export async function buildReport(kind: ReportKind, now = new Date()) {
const { start, end } = previousPeriod(kind, now);
const inRange = (col: typeof orders.createdAt | typeof users.createdAt) =>
and(gte(col, start), lt(col, end));
const [[orderStats], [userStats]] = await Promise.all([
db.select({ total: count(), revenue: sum(orders.amount) }).from(orders).where(inRange(orders.createdAt)),
db.select({ total: count() }).from(users).where(inRange(users.createdAt)),
]);
return {
kind,
start,
end,
orders: orderStats.total,
revenue: Number(orderStats.revenue ?? 0),
newUsers: userStats.total,
};
}
Swap orders and users for whatever matters in your app: appointments, signups, errors, page views, anything you can count.
Step 3: turn it into an email and send it
I send my reports with Nodemailer straight over SMTP, using a mailbox on my own domain. Hosted email APIs are great, but their free tiers cap how many emails you can send a day or a month. With Nodemailer there's no extra service and no API quota in the middle. The only limits are your mail server's own sending limits, which a few report emails a day won't come close to.
npm install nodemailer
npm install -D @types/nodemailer
// src/reports/send.ts
import nodemailer from "nodemailer";
import { buildReport } from "./build";
import type { ReportKind } from "./period";
// Create the transporter once and reuse it for every report
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST, // e.g. mail.yourdomain.com
port: Number(process.env.SMTP_PORT ?? 465),
secure: Number(process.env.SMTP_PORT ?? 465) === 465, // true for 465, false for 587 (STARTTLS)
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const fmtDate = (d: Date) => d.toISOString().slice(0, 10);
export async function sendReport(kind: ReportKind) {
const r = await buildReport(kind);
const label = kind[0].toUpperCase() + kind.slice(1);
const range = `${fmtDate(r.start)} to ${fmtDate(new Date(r.end.getTime() - 1))}`;
await transporter.sendMail({
from: `"Reports" <${process.env.SMTP_USER}>`,
to: process.env.REPORT_EMAIL!,
subject: `${label} report: ${range}`,
html: `
<h2>${label} report</h2>
<p>${range}</p>
<table cellpadding="6">
<tr><td>Orders</td><td><strong>${r.orders}</strong></td></tr>
<tr><td>Revenue</td><td><strong>$${r.revenue.toFixed(2)}</strong></td></tr>
<tr><td>New users</td><td><strong>${r.newUsers}</strong></td></tr>
</table>
`,
});
}
A few things I learned the hard way with SMTP:
- Send from the same mailbox you log in with. If the
fromaddress doesn't matchSMTP_USER, many servers reject the email or it lands in spam. - Set up SPF and DKIM for your domain (cPanel has an "Email Deliverability" page that does this for you). Without them, Gmail is much more likely to send your reports to spam.
- Check the connection once at startup with
await transporter.verify(). A wrong password then fails loudly on deploy, not silently at midnight.
A tip that makes reports much more useful: also build the report for the period before and show the change ("Revenue: $1,240, up 18% on last week"). A number on its own tells you little. A trend tells you what to do next.
Step 4: schedule all three
// src/jobs/reports.ts
import { Cron } from "croner";
import { sendReport } from "../reports/send";
import type { ReportKind } from "../reports/period";
const schedules: Record<ReportKind, string> = {
daily: "0 7 * * *", // every day, 07:00
weekly: "0 7 * * 1", // Mondays, 07:00
monthly: "0 7 1 * *", // 1st of the month, 07:00
};
for (const [kind, pattern] of Object.entries(schedules) as [ReportKind, string][]) {
new Cron(pattern, { name: `${kind}-report`, timezone: "Africa/Lagos", protect: true }, async () => {
try {
await sendReport(kind);
console.log(`[${kind}-report] sent`);
} catch (err) {
console.error(`[${kind}-report] failed`, err);
}
});
}
That's it. Three emails, forever, from about 100 lines of TypeScript. On Vercel you'd do the same thing with three entries in vercel.json pointing at /api/cron/report?kind=daily and so on.
In my own setup, the scheduler isn't inside the app at all. My API exposes a protected reports endpoint, and cPanel's cron calls it every morning. I'll show exactly how in the cPanel section below.
Making the report safe to run twice
If the job ever runs twice (a deploy at 07:00, a retry, two server instances), you don't want two copies of the same report. I keep a tiny table that records which reports were sent:
// report_runs: kind text, period_start timestamptz, sent_at timestamptz,
// unique (kind, period_start)
const inserted = await db
.insert(reportRuns)
.values({ kind, periodStart: r.start, sentAt: new Date() })
.onConflictDoNothing()
.returning();
if (inserted.length === 0) return; // this period's report already went out
Put that right before sending the email. The unique constraint means only the first run "claims" the period. Every other run sees the row already exists and stops.
The catch with in-process schedulers
node-cron and croner run inside your Node process. That has two consequences:
- If the process isn't running, the job doesn't run. On serverless platforms (Vercel functions, AWS Lambda) there is no long-running process, so in-process schedulers simply don't work there. On free tiers that sleep when idle (like Render's free web services), your 2 AM job won't run if the server is asleep at 2 AM.
- If you run more than one instance, the job runs more than once. Scale your API to 3 instances and your "send the daily email" job sends it 3 times. More on fixing that below.
This is why platform-level cron exists.
Cron jobs on hosting platforms
Vercel Cron Jobs
On Vercel, a cron job is just an API route that Vercel calls on a schedule. You declare it in vercel.json:
{
"crons": [
{ "path": "/api/cron/cleanup", "schedule": "30 2 * * *" }
]
}
Then write the route. In the Next.js App Router:
// app/api/cron/cleanup/route.ts
import { deleteExpiredSessions } from "@/jobs/cleanup";
export async function GET(request: Request) {
// Only Vercel's scheduler should be able to trigger this
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const deleted = await deleteExpiredSessions();
return Response.json({ ok: true, deleted });
}
Set a CRON_SECRET environment variable in your Vercel project, and Vercel sends it in the Authorization header when it calls your route. Without that check, anyone who finds the URL can trigger your job.
Things to know:
- Schedules on Vercel run in UTC. 2:30 in the expression is 3:30 AM in Lagos (UTC+1).
- The job is limited by your function's max duration, so long jobs need to be split up or moved elsewhere.
- Plan limits apply. On the Hobby plan, cron jobs can run at most once a day, and timing isn't precise to the minute. Check the current limits in Vercel's docs before you rely on it.
GitHub Actions schedules
Free and surprisingly handy for jobs that don't need your server, like hitting an endpoint, running a script, or refreshing data in a repo:
# .github/workflows/nightly.yml
name: Nightly job
on:
schedule:
- cron: "30 1 * * *" # 01:30 UTC = 02:30 Lagos
workflow_dispatch: {} # lets you run it manually from the Actions tab
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npx tsx scripts/nightly.ts
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Things to know: schedules are in UTC, they can start late when GitHub is busy (sometimes by many minutes, so don't use it for anything time-critical), the shortest interval is every 5 minutes, and scheduled workflows on public repos are disabled automatically after 60 days without repository activity.
Render Cron Jobs (and similar)
Render, Railway, Fly.io and others let you create a dedicated cron service: you give it a schedule and a command like node dist/jobs/cleanup.js, and it spins up a container, runs the command, and shuts down. This is basically the Linux crontab, but managed for you. It's a great fit when your job is a script, not an HTTP route.
cPanel Cron Jobs (shared hosting)
A lot of my own cron jobs live here, so this gets the longest section. If your site is on shared hosting (Namecheap, Hostinger, Bluehost, or any host with cPanel), you already have cron. There's no terminal or crontab -e needed. In cPanel, search for Cron Jobs and you get a form with five boxes (minute, hour, day, month, weekday), a Common Settings dropdown that fills them in for you ("Once Per Day", "Once Per Week" and so on), and a Command box.
Here's an example list, similar in shape to what I run (times changed):
| Minute | Hour | Day | Month | Weekday | What it runs |
|---|---|---|---|---|---|
*/5 |
* |
* |
* |
* |
Health check: curl the API's health endpoint |
0 |
1 |
* |
* |
* |
Database backup, nightly |
0 |
4 |
* |
* |
0 |
Media backup, weekly |
0 |
5 |
1 |
* |
* |
Monthly maintenance |
0 |
7 |
* |
* |
* |
Trigger the reports endpoint |
0 |
9 |
* |
* |
* |
Reminder emails |
<!-- Dara: don't publish a screenshot of your real cPanel cron list. It shows your real paths, endpoint names, app folders and exact backup times. If you want an image, set up a demo job list (or edit the screenshot so every path and domain is generic). -->
Two patterns cover almost everything I do on cPanel.
Pattern 1: call an HTTP endpoint with curl. My Node API runs elsewhere, so cPanel's job is just to knock on the door at the right time. The endpoint does the real work, and a shared secret stops anyone else from triggering it:
curl -fsS -m 300 -X POST \
-H "x-cron-secret: $(grep '^CRON_SECRET=' $HOME/my-api/.env | cut -d= -f2-)" \
https://api.example.com/api/cron/reports
-fmakes curl fail on HTTP errors (a 500 counts as a failure, not a success).-sShides the progress bar but still prints errors.-m 300gives up after 5 minutes so a stuck request can't hang forever.- The secret is read from the app's
.envat run time, so it never sits in plain text in the cron list.
On the API side, the route checks the header before doing anything:
// Express example
app.post("/api/cron/reports", async (req, res) => {
if (req.header("x-cron-secret") !== process.env.CRON_SECRET) {
return res.status(401).json({ error: "Unauthorized" });
}
const sent = await runDueReports(); // daily always; weekly on Mondays; monthly on the 1st
res.json({ ok: true, sent });
});
Pattern 2: run a shell script that wraps the task. For backups and app tasks I keep small scripts in a cron/ folder in the project, and cPanel just runs them. One generic run-task.sh takes the task name as an argument, so adding a new job is one new cron line, not a new script:
#!/usr/bin/env bash
# cron/run-task.sh — usage: run-task.sh <task-name>
set -euo pipefail
cd "$HOME/my-api"
# cron has a tiny PATH; point at the node you actually use
export PATH="$HOME/nodevenv/my-api/22/bin:$PATH"
node dist/tasks/run.js "$1" >> "$HOME/logs/cron-$1.log" 2>&1
# the cron line in cPanel
0 6 * * * $HOME/my-api/cron/run-task.sh daily-visits >/dev/null 2>&1
(Remember to chmod +x the script. The nodevenv path is where cPanel's "Setup Node.js App" installs Node. Check yours, because it varies by host.)
Things to know about cPanel cron:
- It runs in the server's time zone, which you don't control on shared hosting. Many hosts use UTC or US time. Run a test job with
dateonce, or check with your host, before trusting a 07:00 schedule. - Many shared hosts don't allow jobs more often than every 5 or 15 minutes, and some limit how long a job may run.
- Cron Email. At the top of the page, cPanel lets you set an email address. Cron sends any output from a job to that address. That's why every example you see ends with
>/dev/null 2>&1: it throws the output away so you don't get an email every 5 minutes.
Here's the trick I like: >/dev/null 2>&1 throws away errors too, which means failures are silent. If you drop the 2>&1 and keep only >/dev/null, normal output is discarded but errors still reach cron, so cPanel only emails you when something breaks. Combined with curl -fsS (quiet on success, loud on failure), you get free failure alerts:
*/5 * * * * curl -fsS -m 60 https://api.example.com/api/health >/dev/null
If the API is down, that email lands in your inbox within 5 minutes.
Which one should you use?
| Situation | Good choice |
|---|---|
| Shared hosting with cPanel | cPanel Cron Jobs (scripts, or curl to your API) |
| You have a VPS and one server | System crontab or croner in your app |
| Your app is on Vercel / serverless | Vercel Cron Jobs (or another platform cron hitting your route) |
| A script that doesn't need your server | GitHub Actions schedule or a Render cron job |
| Many instances, retries, job history | A queue: BullMQ (Redis) repeatable jobs, or pg-boss if you already use Postgres |
For me, cPanel cron is often the first stop, because it's already there, it costs nothing extra, and a curl to a protected endpoint works for an API hosted anywhere.
The mistakes that will bite you (and how to avoid them)
This section is the real reason to read a cron guide. The syntax takes ten minutes to learn. These lessons usually take a few broken nights.
1. Time zones
Cron runs in the server's time zone, and most servers and platforms are on UTC. If you're in Lagos (UTC+1) and you write 0 9 * * * on a UTC server, your "9 AM" job runs at 10 AM your time. For users in New York, it's 4 or 5 AM depending on the season.
Fixes:
- Decide on purpose: store everything in UTC and convert, or set an explicit
timezoneoption in your scheduler. - Write the time zone in a comment next to every schedule:
"30 1 * * *" // 01:30 UTC = 02:30 Lagos. - If you're scheduling things for users ("remind me at 9 AM my time"), don't create a cron per user. Run one job every few minutes that finds everything due right now.
2. Daylight saving time
Nigeria doesn't change its clocks, but the US, UK, and Europe do. In a DST-observing time zone, a job scheduled for 2:30 AM may be skipped on the night clocks jump forward and run twice on the night they fall back. If a job must run exactly once a day, schedule it in UTC or at an hour that DST never touches.
3. Overlapping runs
You schedule a job every 5 minutes. One day the database is slow and a run takes 7 minutes. Now two copies run at the same time, both grabbing the same rows, both sending the same emails.
Fixes:
- Use a scheduler option that skips a run if one is in progress (
protect: truein croner). - Use a lock. If you're on Postgres, an advisory lock is a neat trick:
// Drizzle with node-postgres
import { sql } from "drizzle-orm";
import { db } from "../db";
export async function withLock<T>(lockId: number, fn: () => Promise<T>): Promise<T | null> {
return db.transaction(async (tx) => {
const { rows } = await tx.execute<{ locked: boolean }>(
sql`select pg_try_advisory_xact_lock(${lockId}) as locked`
);
if (!rows[0]?.locked) return null; // another run (or another server) has it
return fn(); // the lock is released automatically when the transaction ends
});
}
// Usage
await withLock(1001, () => sendAppointmentReminders());
This also solves the "3 instances send 3 emails" problem: only one instance gets the lock. I use the _xact_ version on purpose. It's tied to the transaction, so it can't leak if you're using a connection pool, and it's released even if the job throws.
4. Jobs that aren't idempotent
Idempotent means running the job twice has the same result as running it once. Cron jobs will run twice sometimes: a retry, a deploy at the wrong moment, a DST fall-back, two instances. Design for it.
Bad: "send a reminder to every appointment tomorrow." Good: "send a reminder to every appointment tomorrow where reminderSentAt is null, then set reminderSentAt."
export async function sendAppointmentReminders(now = new Date()) {
const due = await db.query.appointments.findMany({
where: (a, { and, gte, lt, isNull }) =>
and(
gte(a.startsAt, addHours(now, 24)),
lt(a.startsAt, addHours(now, 25)),
isNull(a.reminderSentAt)
),
});
for (const appt of due) {
await sendReminderEmail(appt);
await db
.update(appointments)
.set({ reminderSentAt: new Date() })
.where(eq(appointments.id, appt.id));
}
return due.length;
}
Now a second run finds nothing to do. That one column has saved me from a lot of embarrassment.
5. Silent failures
The worst thing about cron is that when it breaks, nothing tells you. There's no user clicking a button and seeing an error. The backup just quietly stops happening, and you find out the day you need the backup.
Fixes:
- Log every run: start time, end time, how many items it processed, and errors.
- Let errors reach your inbox. On cPanel (or any crontab with
MAILTOset), redirect only normal output with>/dev/nulland leave errors alone, so cron emails you only when a job fails. - Use a "dead man's switch." Services like Healthchecks.io or Cronitor give you a URL to ping at the end of each successful run. If the ping doesn't arrive on time, they alert you. This catches the failure mode logs can't: the job not running at all.
async function runWithHeartbeat(name: string, job: () => Promise<unknown>) {
const started = Date.now();
try {
await job();
console.log(`[${name}] ok in ${Date.now() - started}ms`);
if (process.env.HEARTBEAT_URL) await fetch(process.env.HEARTBEAT_URL);
} catch (err) {
console.error(`[${name}] failed after ${Date.now() - started}ms`, err);
if (process.env.HEARTBEAT_URL) await fetch(`${process.env.HEARTBEAT_URL}/fail`);
}
}
6. Doing too much in one run
A job that processes "all users" works fine with 200 users and times out with 200,000. Process in batches (say 500 rows at a time), and if you're on a platform with time limits, let the job pick up where it left off on the next run.
7. Testing only in production
You don't want to wait until 2:30 AM to find out your job is broken. Because the logic lives in a plain function, you can:
- call it from a unit test with a fixed
now, - run it once by hand with a script (
npx tsx scripts/run-cleanup.ts), - add a
workflow_dispatchtrigger or a protected admin route to run it on demand, - temporarily schedule it with
* * * * *locally and watch it fire every minute.
A production checklist for every cron job
Before I ship a scheduled job, I run through this:
- I pasted the expression into crontab.guru and the plain English matches what I want.
- I know which time zone it runs in, and there's a comment saying so.
- The job logic is a plain function I can call and test without the scheduler.
- Running it twice in a row is safe (idempotent).
- It can't overlap with itself, and multiple instances won't all run it.
- Errors are caught and logged, not swallowed.
- Something alerts me if it fails or stops running.
- It processes work in batches and fits inside the platform's time limit.
- If it's an HTTP route, it's protected with a secret.
Wrapping up
Cron is one of those ideas that's almost 50 years old and still everywhere. Five fields, one rule ("run this when the clock matches"), and suddenly your backend can take care of itself while you sleep.
If you're coming from the frontend like I did, a cron job is a great first backend project. My suggestion: start with a daily report emailed to yourself. It's small, it's useful from day one, and it teaches you queries, dates, email, and scheduling all at once. Then add the weekly and monthly versions. Then make it idempotent, make it loud when it fails, and you've learned half of what "production backend" really means.
FAQ
What's the difference between cron and a crontab?Cron is the program that runs scheduled tasks. A crontab is the file that lists those tasks and their schedules.
Can a cron job run every second?Standard cron's smallest unit is one minute. Libraries like node-cron and croner support a sixth seconds field, but if you need something to run every second, a loop or a queue worker is usually a better fit than cron.
Do cron jobs work on Vercel or other serverless platforms?Not with in-process libraries like node-cron, because there's no long-running process. Use the platform's own cron feature (Vercel Cron Jobs) to call an API route on a schedule.
Why didn't my cron job run?The usual suspects: the server or process was asleep or down, the time zone is different from what you expected, a command isn't on cron's PATH, the script failed and the output wasn't logged, or (on GitHub Actions) the schedule was disabled for inactivity.
How do I stop a cron job from running twice at the same time?Use an overlap-protection option in your scheduler, a lock (like a Postgres advisory lock or a Redis lock), and make the job idempotent so a double run does no harm.