Troubleshooting · Vercel

Vercel cron job not running? Check these first

Why a Vercel cron job doesn't run or fire — production-only deploys, Hobby-plan once-a-day limits and hour-window timing, CRON_SECRET 401s, maxDuration timeouts — and how to fix each.

Vercel crons are convenient right up until they aren’t. Most “it never runs” reports come down to one of five things — walk them in order.

30-second diagnosis:

SymptomMost likely causeJump to
Never runs, dashboard shows no cronConfig not in a production deploy§1
Runs at a seemingly random timeHobby-plan hour-window timing§2
Runs but returns 401/403Auth middleware / CRON_SECRET§3
Starts but never finishesmaxDuration timeout§4
Runs at the “wrong” hourSchedules are UTC-only§5

1. Crons only run on the production deployment

Cron jobs defined in vercel.json are activated only for the current production deployment. They don’t run on preview deployments, and changes to the crons config don’t take effect until you deploy to production again.

{
  "crons": [
    { "path": "/api/reports/nightly", "schedule": "0 3 * * *" }
  ]
}

Check the dashboard: Project → Settings → Cron Jobs should list the job with its next scheduled run. If it’s missing there, the config never made it into a production deploy. Common ways that happens:

  • the vercel.json change was merged but the production deploy failed or was skipped;
  • the file lives in a subdirectory that isn’t the project root Vercel builds;
  • a typo in the crons key — Vercel silently ignores config it doesn’t recognize.

Also note crons are paused when a project is paused and don’t fire on rolled-back deployments’ old configs — the current production deployment is the single source of truth.

2. On the Hobby plan, timing is approximate — by design

Hobby-plan crons are limited to 2 cron jobs that can run at most once per day, and the invocation can happen anytime within the hour of the scheduled time. 0 3 * * * means “sometime between 03:00 and 03:59 UTC” — not 03:00. This is documented behavior, not a bug, and it regularly surprises people who set a cron for “midnight” and see it fire at 00:47.

Two follow-on effects worth knowing:

  • A schedule more frequent than daily (say */10 * * * *) is invalid on Hobby — the deploy warns and the cron won’t run as written.
  • Because the window is an hour wide, two consecutive runs can be as close as ~23 hours or as far as ~25 hours apart. Anything that assumes “exactly 24h since last run” needs slack.

If you need minute-level precision or intraday schedules, that requires the Pro plan — or an external scheduler hitting the same route with exact timing (see the last section). A deeper dive into the plan limits: Vercel cron limits explained.

3. The route rejects the request (401/403)

Vercel invokes your cron as a plain GET request. If the route sits behind your own auth middleware, the cron gets rejected like any anonymous caller.

The supported pattern: set a CRON_SECRET environment variable. Vercel then sends it along as a bearer token, and you verify it in the handler:

export function GET(req: Request) {
  const auth = req.headers.get("authorization");
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response("Unauthorized", { status: 401 });
  }
  // ... the actual job
}

Three gotchas around this:

  • After adding or rotating CRON_SECRET, redeploy — env changes don’t apply to the running production deployment.
  • Global middleware (middleware.ts) runs before your handler — if it redirects unauthenticated requests to a login page, your cron gets a 307 and the handler never executes. Exempt the cron path or check the header in middleware.
  • Deployment protection (password/SSO-protected previews and even production) will block cron invocations the same way it blocks anonymous visitors.

4. The function times out mid-job

A cron invocation is a normal serverless function call, with the same maxDuration limits as any other. A nightly job that grew past the limit gets killed mid-run — the schedule fires, the work doesn’t finish, and unless the function logs its own progress you’ll only see the timeout entry.

Check Logs / Observability for Task timed out entries and raise maxDuration in the route config (limits depend on plan):

export const maxDuration = 300; // seconds — route segment config

If the job legitimately needs longer than your plan’s ceiling, split it into batches, or move the heavy work to a queue and let the cron only enqueue.

5. The schedule is UTC and 5-field only

No timezone support, no seconds field, no @daily shortcuts. 0 9 * * * is 09:00 UTC year-round — your Berlin 9 AM job drifts an hour with daylight saving time, and there is no setting to fix that inside Vercel.

If you’re unsure what an expression does, check it against the cron expression explainer or the common schedule reference — both show concrete next-run times.

The deeper problem: no retries, no alerts

Vercel cron gives you a trigger and a log — nothing else. A failed run isn’t retried. Nobody is emailed. A job that 500s every night for three weeks looks exactly like a healthy one unless you go read the logs.

Two robust upgrades, in increasing order of control:

  1. Keep the Vercel cron, add a heartbeat. Last line of the handler pings a monitor; a missed or failed run alerts you within your grace period:

    await fetch("https://ping.steadycron.com/<your-ping-token>");
    

    Because the ping only fires when the handler finishes, this catches all of the above at once: missing production deploys, 401s, timeouts, and plain crashes. Setup in heartbeat monitoring.

  2. Move the trigger out of Vercel. Keep the route, delete the crons config, and have an external scheduler call it — with exact timing, per-job timezones, retries with backoff, and an execution log of every response. That’s exactly what SteadyCron’s HTTP jobs do; the route stays protected by the same CRON_SECRET header, and steadycron import vercel converts your existing vercel.json in one command. See migrating from Vercel cron.

Frequently asked questions

Why is my Vercel cron job not running at all?

Most often the crons config never reached a production deployment — cron jobs only activate on the current production deploy, never on previews. Check Project → Settings → Cron Jobs: if the job isn't listed with a next-run time, redeploy to production.

How many cron jobs can I have on the Vercel Hobby plan?

The Hobby plan allows 2 cron jobs, each running at most once per day, and the invocation lands anytime within the scheduled hour. Minute-level precision and more frequent schedules require the Pro plan — or an external scheduler calling the same route.

Why does my Vercel cron run at a random time within the hour?

That's documented Hobby-plan behavior: 0 3 * * * means "sometime between 03:00 and 03:59 UTC", not 03:00 sharp. It's a deliberate load-spreading measure, not a bug.

Why does my Vercel cron return 401 Unauthorized?

Vercel invokes crons as plain GET requests. If your route checks auth, set a CRON_SECRET environment variable — Vercel sends it as a bearer token — and verify it in the handler. Remember to redeploy after adding or rotating the secret.

Does Vercel retry a failed cron job or alert me?

No. A failed run isn't retried and no notification is sent — a job that 500s every night looks identical to a healthy one unless you read the logs. Add a heartbeat ping or move the trigger to a scheduler with retries and alerting.