TL;DR: A cron job is scheduled code that runs automatically at specific times — no user click required. The classic syntax looks like 0 9 * * * (five numbers/asterisks that define when to run). For Node.js apps with a persistent server, use node-cron. For Vercel apps, use Vercel Cron in your vercel.json. For anything that needs retries or multi-step logic, use Inngest or Trigger.dev. Common uses: daily emails, database cleanup, scheduled reports, and API polling.

Why AI Coders Need This

When you first build with AI, the app responds to user actions. Click a button, something happens. Submit a form, data gets saved. That model works for most features. Then you hit something different.

You want to send users a summary of their activity every Monday morning. There's no "every Monday" trigger in a request/response flow — users aren't clicking anything at 8 AM on Mondays. You want to delete records older than 90 days so the database doesn't balloon. Nobody is going to log in and click "clean up old data." You want to pull fresh prices from an external API every hour so your pricing page doesn't show stale numbers. No user action kicks that off.

These are all scheduling problems. The app needs to take action on its own clock, not on the user's schedule. Cron jobs solve this. They've been the standard way to schedule code since the 1970s, they're built into Linux servers, and every major Node.js hosting platform has some version of them.

Understanding cron jobs means your app can do things proactively — not just reactively. That's the mental model shift this article delivers.

Real Scenario: The Overdue Invoice Checker

Let's ground this in something real. You're building a small SaaS tool for freelancers — it tracks invoices and clients. A freelancer named Dana uses it. She sends invoices, marks them paid, everything's great. Then she asks: "Can the app remind me every morning which invoices are more than 30 days overdue?"

Without cron: you could add a dashboard widget that shows overdue invoices when Dana logs in. But she'd have to log in to see it. Some mornings she doesn't. Some months she goes two weeks without checking.

With cron: every morning at 8 AM, your app runs a small piece of code. It queries the database for all invoices where due_date is more than 30 days ago and status is not paid. For each freelancer with overdue invoices, it sends a summary email. Dana gets an email. She doesn't have to do anything. The app does its job.

That "run this code every morning at 8 AM" instruction is exactly what a cron job expresses. Now let's look at how that instruction is written.

Understanding the Cron Syntax

Cron schedules are written as a string of five fields separated by spaces. The string looks cryptic the first time you see it. Once you understand what the five fields mean, you can read and write most common schedules without a reference.

The five fields

# ┌─────────── minute        (0–59)
# │ ┌─────── hour          (0–23)
# │ │ ┌───── day of month  (1–31)
# │ │ │ ┌─── month         (1–12)
# │ │ │ │ ┌─ day of week   (0–6, 0=Sunday)
# │ │ │ │ │
  * * * * *

An asterisk (*) means "every possible value." Five asterisks means "every minute of every hour of every day" — basically, run constantly. You'll almost never want that. You narrow it down by replacing asterisks with numbers.

Reading common expressions

Expression Means
0 9 * * * Every day at 9:00 AM
0 0 * * * Every day at midnight
0 * * * * Every hour (on the hour)
*/15 * * * * Every 15 minutes
0 9 * * 1 Every Monday at 9:00 AM
0 0 1 * * First day of every month at midnight
30 8 * * 1-5 Weekdays (Mon–Fri) at 8:30 AM
0 2 * * 0 Every Sunday at 2:00 AM

The / syntax means "every N." So */15 in the minute field means "every 15 minutes." */2 in the hour field means "every 2 hours."

The - syntax means a range. 1-5 in the day-of-week field means Monday through Friday.

Pro tip: You never need to memorize cron syntax. Go to crontab.guru and type any expression — it shows you exactly what it means in plain English, and you can edit it interactively. AI also generates cron expressions reliably when you describe the schedule: "every weekday at 8:30 AM" → 30 8 * * 1-5.

Timezones: the hidden cron gotcha

Standard cron runs in the server's local timezone, which is usually UTC. If you deploy to a cloud platform, the server is almost always UTC. So 0 9 * * * runs at 9 AM UTC — which is 4 AM or 5 AM if your users are on the US East Coast, and 1 AM or 2 AM for the US West Coast. Always decide which timezone you're scheduling in and document it. Most modern scheduling tools let you specify a timezone explicitly. node-cron does not natively — you have to calculate the UTC equivalent yourself or use a timezone-aware wrapper.

node-cron for Node.js Apps

node-cron is a lightweight npm package that runs cron-style scheduled functions inside your Node.js process. You install it, import it, call cron.schedule() with a cron expression and a callback, and it fires on that schedule as long as your server is running.

npm install node-cron
// lib/scheduled-jobs.ts
import cron from 'node-cron';
import { checkOverdueInvoices } from '@/lib/invoices';
import { sendDailyDigest } from '@/lib/emails';
import { purgeExpiredSessions } from '@/lib/auth';

// Check overdue invoices every morning at 8 AM UTC
cron.schedule('0 8 * * *', async () => {
  console.log('[cron] Running overdue invoice check...');
  try {
    await checkOverdueInvoices();
  } catch (err) {
    console.error('[cron] overdue invoice check failed:', err);
  }
});

// Send daily digest at 9 AM UTC
cron.schedule('0 9 * * *', async () => {
  console.log('[cron] Sending daily digest...');
  try {
    await sendDailyDigest();
  } catch (err) {
    console.error('[cron] daily digest failed:', err);
  }
});

// Purge expired auth sessions every hour
cron.schedule('0 * * * *', async () => {
  await purgeExpiredSessions();
});

Then you import this file once in your app's entry point so the schedules get registered when the server starts:

// server.ts (or wherever you boot your Express/Fastify/Hono app)
import './lib/scheduled-jobs'; // registers all cron schedules
import { app } from './app';

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

node-cron only works in a persistent server process. If you're on Vercel, Railway serverless, or any platform where functions spin up per request and then shut down, node-cron never persists between requests. The schedule is registered when the function boots, then the function exits, and the schedule disappears. For serverless platforms, use Vercel Cron or Inngest instead.

Stopping and managing schedules

node-cron returns a task object you can use to pause, resume, or stop a schedule:

const task = cron.schedule('0 9 * * *', async () => {
  await sendDailyDigest();
}, {
  scheduled: false,  // don't start immediately
  timezone: 'America/New_York',  // requires node-cron v3+
});

task.start();   // begin the schedule
task.stop();    // stop the schedule permanently
task.destroy(); // stop and clean up the task object

Cron on Linux Servers

If you're running your app on a Linux VPS — a DigitalOcean Droplet, a Hetzner server, an AWS EC2 instance — you have direct access to the system's cron daemon, which has been part of Unix systems since the 1970s.

Linux cron is configured through a file called the crontab. Each user on the system has their own crontab. You edit it with:

crontab -e

This opens a text editor where you add lines, one per scheduled job:

# Run a Node.js script every day at 9 AM
0 9 * * * /usr/bin/node /home/deploy/app/scripts/daily-digest.js >> /var/log/daily-digest.log 2>&1

# Run a shell script every hour
0 * * * * /home/deploy/scripts/purge-sessions.sh

# Run database backup every Sunday at 2 AM
0 2 * * 0 /home/deploy/scripts/backup-db.sh

The >> /var/log/daily-digest.log 2&>1 part redirects the script's output (including error messages) to a log file, so you can check what happened when the job ran. Without this, output disappears silently.

The difference between Linux cron and node-cron: Linux cron is managed by the operating system — it runs even if your Node.js app crashes, it survives server reboots, and it can run any command (shell scripts, Python, Node.js files). node-cron runs inside your Node process — it stops if your app stops. For production apps on VPS, system cron is often more reliable for critical jobs like database backups.

One important use case for Linux system cron is database backups. A cron job that runs every night at 2 AM, dumps your database to a file, and uploads it to cold storage is the kind of low-drama insurance policy every app should have. See the database backup guide for how to set that up.

Vercel Cron

If you're hosting on Vercel, you don't have a persistent server process — so node-cron won't work. Vercel's answer is Vercel Cron: a feature that calls one of your Next.js API routes on a schedule, from Vercel's infrastructure, outside your function's normal lifecycle.

You configure it in vercel.json at the root of your project:

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/daily-digest",
      "schedule": "0 9 * * *"
    },
    {
      "path": "/api/cron/purge-sessions",
      "schedule": "0 * * * *"
    },
    {
      "path": "/api/cron/weekly-report",
      "schedule": "0 8 * * 1"
    }
  ]
}

Then you create each API route. The route should verify the request came from Vercel (not someone guessing the URL) and do its work:

// app/api/cron/daily-digest/route.ts
import { NextResponse } from 'next/server';
import { sendDailyDigest } from '@/lib/emails';

export async function GET(request: Request) {
  // Verify this request came from Vercel's cron system
  const authHeader = request.headers.get('authorization');
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    const result = await sendDailyDigest();
    return NextResponse.json({ success: true, sent: result.count });
  } catch (err) {
    console.error('Daily digest cron failed:', err);
    return NextResponse.json({ error: 'Job failed' }, { status: 500 });
  }
}

Set CRON_SECRET to any random string in your Vercel environment variables. Vercel automatically passes it in the Authorization header when it calls your cron endpoint. This prevents anyone else from triggering your cron routes by hitting the URL directly.

Vercel Cron limits: On the Hobby (free) plan, you get one cron job maximum and the minimum schedule frequency is once per day. On Pro, you get more jobs and can run as frequently as once per minute. Cron routes still obey the normal function timeout limits — a cron job that takes longer than your function timeout will be killed. For long-running scheduled tasks, use Inngest or Trigger.dev instead.

Common Use Cases in AI-Built Apps

Here are the recurring task categories that come up again and again when building with AI. For each one, a short description of what the cron job does and a sample prompt you can give AI.

Daily email digests and reminders

The most common scheduled task. Every morning (or week, or at a custom interval the user controls), aggregate information and send an email. Could be: overdue invoices, new activity in a project, summary of usage stats, or a motivational nudge.

Prompt I Would Type

Add a daily digest email that runs every morning at 8 AM UTC. For
each user with the digest_enabled flag set to true, query their
invoices from the last 7 days and send a summary email using
Resend. Use Vercel Cron since we're on Vercel.

Database cleanup and maintenance

Left unchecked, databases fill up with abandoned records: password reset tokens that were never used, email verification codes that expired, anonymous sessions, soft-deleted records that were never hard-deleted. A regular cleanup cron keeps the database lean and prevents slow queries caused by large tables.

Prompt I Would Type

Add a node-cron job that runs every night at 2 AM and deletes:
- Password reset tokens older than 24 hours
- Email verification tokens older than 48 hours
- Anonymous sessions older than 30 days
Log how many records were deleted each time.

External API polling

Some data sources don't push updates to you — you have to ask. If you're showing stock prices, weather, sports scores, or anything from an API that doesn't offer webhooks, you poll it on a cron schedule and cache the result. Users get fast reads from your database; the cron keeps it fresh.

Prompt I Would Type

Add a cron job that runs every 30 minutes and fetches the current
exchange rates from the Open Exchange Rates API. Store the result
in a currency_rates table with a timestamp. The rest of the app
should read from this table instead of calling the API directly.

Scheduled reports and summaries

Weekly business reports, monthly invoicing summaries, quarterly usage reviews. These are often the most valuable emails an app sends — users actually look forward to them. The cron generates the report, formats it as HTML, and sends it. Could also generate a PDF and attach it.

Subscription and billing checks

If you're handling subscriptions outside a platform like Stripe (which has its own webhooks), a daily cron that checks for expired subscriptions, downgrades accounts, and sends "your trial ends tomorrow" emails is essential. Even if you're on Stripe, a cron that verifies local subscription state matches Stripe's records helps catch edge cases where webhooks were missed.

Stale data refresh and cache warming

Some data is expensive to compute but changes slowly. A daily cron that pre-computes analytics, generates leaderboards, or warms up a cache means the first user to request that data doesn't pay the computation cost. The cron does the heavy lifting off-hours; users get instant results.

Alternatives: Inngest and Trigger.dev

node-cron and Vercel Cron are simple and get the job done. But they have real limitations: no retry logic, no visibility into what ran and when, and no way to run multi-step jobs. When your scheduled tasks need more robustness, two platforms stand out.

Inngest scheduled functions

Inngest is primarily known as a background job platform, but it supports scheduled functions natively. You define a function with a cron trigger instead of an event trigger, and Inngest calls it on that schedule. The same durability and retry guarantees that apply to event-driven jobs apply to scheduled ones.

// inngest/functions/weekly-report.ts
import { inngest } from '@/inngest/client';

export const weeklyReport = inngest.createFunction(
  {
    id: 'weekly-report',
    retries: 2,   // retry up to 2 times if the function throws
  },
  { cron: '0 8 * * 1' },  // every Monday at 8 AM UTC
  async ({ step }) => {
    // step.run isolates each piece of work
    const users = await step.run('fetch-active-users', async () => {
      return db.user.findMany({ where: { digest_enabled: true } });
    });

    const reports = await step.run('generate-reports', async () => {
      return Promise.all(users.map(u => generateWeeklyReport(u.id)));
    });

    await step.run('send-emails', async () => {
      await Promise.all(
        reports.map(r => sendReportEmail(r.userId, r.data))
      );
    });

    return { sent: reports.length };
  }
);

What you get over a basic cron:

  • Retry on failure — if the email service is down at 8 AM Monday, Inngest retries automatically with backoff
  • Observability — Inngest's dashboard shows every run, whether it succeeded, and the output of each step
  • Multi-step durability — if the function crashes after step 2, it resumes from step 3 on retry, not from the beginning
  • Works on serverless — no persistent process required

Trigger.dev

Trigger.dev is a newer platform with a similar premise: write background jobs and scheduled tasks as regular TypeScript functions, deploy them alongside your app, and get a dashboard to monitor them. Trigger.dev has strong support for long-running tasks and a generous free tier.

// trigger/weekly-report.ts (Trigger.dev v3 syntax)
import { schedules } from '@trigger.dev/sdk/v3';

export const weeklyReportTask = schedules.task({
  id: 'weekly-report',
  cron: '0 8 * * 1',   // every Monday at 8 AM UTC
  run: async (payload) => {
    const users = await db.user.findMany({
      where: { digest_enabled: true },
    });

    for (const user of users) {
      const report = await generateWeeklyReport(user.id);
      await sendReportEmail(user.id, report);
    }

    return { sent: users.length };
  },
});

Trigger.dev and Inngest cover roughly the same ground. The practical difference: Inngest integrates more tightly with Next.js and Vercel's ecosystem; Trigger.dev has a more flexible deployment model and arguably simpler syntax. AI generates working code for both — pick based on whichever fits your stack. See the full comparison in the background jobs guide.

Tool Works On Retries Dashboard Best For
node-cron Persistent servers only No No Simple tasks, persistent Node apps
Vercel Cron Vercel only No Vercel logs Simple tasks on Vercel
Linux cron Linux VPS only No No (log files) Server scripts, DB backups
Inngest Serverless + persistent Yes Yes Complex tasks, multi-step, Next.js
Trigger.dev Serverless + persistent Yes Yes Complex tasks, long-running jobs

What AI Gets Wrong About Cron Jobs

It uses node-cron inside a serverless function

This is the most common mistake. You ask AI to add a scheduled task to your Vercel app. It generates code using node-cron inside an API route or a middleware file. The code looks reasonable. It does nothing in production. Serverless functions are ephemeral — the process starts, handles a request, and exits. node-cron registers a schedule that never fires because the process isn't around when the scheduled time arrives.

Fix: If you're on Vercel, use Vercel Cron or Inngest. If you're not sure which platform you're on, tell AI explicitly: "I'm on Vercel, serverless, no persistent Node process."

It doesn't add authentication to Vercel Cron endpoints

AI often generates the API route for a Vercel Cron job without the authorization check. This means anyone who discovers your cron URL can trigger it manually — potentially sending mass emails or running expensive operations. Always add the CRON_SECRET check shown in the Vercel Cron section above.

It ignores timezones

AI generates 0 9 * * * for "every morning at 9 AM" without asking which timezone. If your users are in New York and the server is UTC, they're getting their morning email at 4 AM. Always tell AI the timezone context: "every morning at 9 AM Eastern Time" or "every Monday at 8 AM in the user's local timezone."

It runs jobs synchronously inside the cron callback

A cron job fires at 9 AM and processes 10,000 user accounts synchronously — one by one, waiting for each email to send before starting the next. This takes 45 minutes, blocks the function (or the process), and misses the 10 AM cron if they overlap. The right pattern: the cron job enqueues work for each user, and a queue of workers processes them in parallel. The cron kicks off the work; it doesn't do the work itself. See the background jobs guide for the queue pattern.

It doesn't handle errors or log anything

AI-generated cron callbacks often have no try/catch and no logging. A job fails, nothing is recorded, and you find out when users complain a week later. Wrap every cron callback in a try/catch. Log the start, end, and any errors. Consider logging to a structured logging service so you can search and alert on failures.

Always wrap cron callbacks in try/catch: An uncaught error in a node-cron callback can crash your entire Node.js process. Every scheduled job should catch its own errors so a failing cron doesn't take down the rest of your app.

How to Debug Cron Jobs

Cron jobs are tricky to debug because they don't run on demand — you have to wait for the scheduled time or find a way to trigger them manually. Here's the approach that works.

Test the function directly first

Extract the logic from the cron callback into its own function. Test that function in isolation — call it from a script, a test, or a temporary API endpoint. Confirm it works before wrapping it in a schedule. Most bugs are in the business logic, not the scheduling.

// Test the function directly before scheduling it
import { checkOverdueInvoices } from '@/lib/invoices';

// Run this script with: npx ts-node scripts/test-overdue.ts
async function main() {
  const result = await checkOverdueInvoices();
  console.log('Result:', result);
}

main().catch(console.error);

Add a manual trigger endpoint in dev

Add a temporary API route that calls your cron logic on demand. Remove it before deploying to production (or gate it behind an admin check).

// app/api/dev/trigger-cron/route.ts — dev only, remove before production
export async function GET() {
  if (process.env.NODE_ENV !== 'development') {
    return Response.json({ error: 'Dev only' }, { status: 403 });
  }
  await sendDailyDigest();
  return Response.json({ success: true });
}

Use short schedules during testing

When testing a node-cron job, temporarily change the schedule to run every minute (* * * * *) so you can verify it fires without waiting for the real schedule. Change it back before committing.

Check logs

For Vercel Cron, check Vercel's function logs filtered to your cron endpoint. For node-cron, add explicit logging at the start and end of each job. For Linux cron, redirect output to a log file as shown in the crontab example. Without logs, debugging cron failures is guesswork.

Verify the cron is registered

For node-cron, add a log statement on server startup that confirms the schedules were registered:

// lib/scheduled-jobs.ts
console.log('[cron] Registering scheduled jobs...');

cron.schedule('0 9 * * *', async () => {
  // ...
});

console.log('[cron] Daily digest scheduled for 9:00 AM UTC');
console.log('[cron] Registered', cron.getTasks().size, 'scheduled tasks');

How to Ask AI to Add Scheduled Tasks

AI is good at generating cron code when you give it the right context. These prompts get better results.

For a Vercel app

I'm on Vercel (Next.js, serverless, App Router). Add a cron job
that runs every day at 8 AM UTC and checks for users whose trial
period expired yesterday. For each expired user, set their account
status to 'trial_expired' in the database and send them an email
using Resend. Use Vercel Cron with a CRON_SECRET auth check.

For a persistent Node.js server

I have a Node.js Express server that runs persistently (not
serverless). Add node-cron scheduled jobs for:
1. Every night at 2 AM: delete password reset tokens older than 24h
2. Every Monday at 9 AM: generate a weekly summary for all active
   users and store it in the weekly_summaries table
Wrap each job in try/catch and log the results.

For Inngest scheduled functions

Using Inngest, add a scheduled function that runs every Sunday at
midnight UTC. It should query all users who have had no activity in
the last 30 days, add them to a re-engagement email sequence, and
update their status to 'at_risk'. Use step.run() for each phase
so the function is resumable on retry.

What to Learn Next

Cron jobs are one piece of the async backend puzzle. Here are the most useful next reads:

Frequently Asked Questions

A cron job is a scheduled task — code your server runs automatically at a specific time or on a recurring schedule, without any user clicking anything. The name comes from the Unix cron system that has existed since the 1970s. Think of it like a calendar reminder for your app: every morning at 9 AM, send the digest email. Every Sunday at midnight, clean up old records. Every hour, check for overdue invoices.

The five fields represent: minute (0–59), hour (0–23), day of month (1–31), month (1–12), and day of week (0–6, where 0 is Sunday). An asterisk (*) means "every." So * * * * * means "run every minute." 0 9 * * * means "run at minute 0 of hour 9, every day, every month, every weekday" — which is 9:00 AM daily. You never need to memorize this — crontab.guru translates any expression to plain English.

Yes, but you can't use node-cron on serverless platforms. node-cron runs inside a long-lived Node.js process — serverless functions spin up and disappear per request, so the scheduler never persists. Use Vercel Cron (configured in vercel.json) instead — it calls one of your API routes on a schedule from outside your function. Alternatively, Inngest's scheduled functions work on any serverless platform.

A background job is triggered by an event — a user action or another piece of code kicks it off and puts it in a queue. A cron job runs on a fixed schedule regardless of what users are doing. Both are types of async tasks that run outside the normal web request cycle. In practice, cron jobs often kick off background jobs: the cron fires at 9 AM and enqueues one email-sending job per user, then the workers process all those jobs in parallel.

Standard cron (on a Linux server) has no retry logic — if the job fails, it fails silently. node-cron behaves the same way. Vercel Cron does not retry missed or failed invocations. Inngest and Trigger.dev do have retry logic for scheduled functions. For important recurring tasks, use a platform that retries on failure and alerts you when the retry limit is hit. For low-stakes cleanup jobs, a simple cron is usually fine.