Vercel cron jobs: setup, limits and examples
To add a cron job on Vercel, list it under crons in vercel.json with a path and a five-field schedule, then deploy to production. Vercel sends a GET request to that path on schedule, always in UTC. On the Hobby plan a job can run at most once a day; Pro and Enterprise allow one every minute.
Six things to know before you rely on it
- There is no time zone setting.
0 9 * * *runs at 09:00 UTC all year, so it moves an hour against local time when daylight saving starts and ends. Hobby runs once a day, within the hour
On the Hobby plan a schedule that runs more than once a day fails to deploy, and a daily job can start any time in its scheduled hour.No names, and only one day field
MONorJANaren't accepted, and when day of month is set, day of week has to be*(and the other way round).- Vercel calls the path on your production deployment. Preview deployments and local dev servers never run cron jobs.
No retries, and runs can be missed or doubled
A failed run isn't retried, delivery is best effort, and the same run can occasionally arrive twice.- A 3xx response ends the run, so a path that redirects (to add a trailing slash or a locale) never reaches your code.
Set up a cron job
A Vercel cron job is an ordinary function that Vercel calls on a schedule. Write the function first; in a Next.js App Router project that's a route handler such as app/api/daily-report/route.ts exporting GET. Then add an entry to the crons array in vercel.json, as in the example file. path must start with /, and schedule is a cron expression as a string.
The job is created when you deploy, and only production deployments are invoked. Vercel requests the path on your production URL with the user agent vercel-cron/1.0. To change or delete a job, edit vercel.jsonand redeploy; the project's Cron Jobs settings page lists active jobs and has a button to disable them. Disabled jobs still count toward the limit of 100 per project, and an Instant Rollback brings back the cron jobs of the deployment you roll back to.
The schedule syntax
schedule takes five fields: minute (0–59), hour (0–23), day of month (1–31), month (1–12) and day of week (0–6, with 0 as Sunday), with *, lists, ranges and steps as in standard cron. Three things differ:
- Names like
MON,SUN,JANorDECaren't supported. Use numbers. - Day of month and day of week can't both be set: when one has a value, the other must be
*. For "the 1st and every Monday", add two cron jobs with the same path. - The time zone is always UTC, and there is no setting to change it.
To check an expression before you deploy, paste it into the cron expression explainer: its Vercel row flags names, combined day fields and schedules too frequent for Hobby, and suggests a rewrite.
Vercel cron examples
| Expression | Runs (UTC) | Use | Hobby? |
|---|---|---|---|
0 5 * * * | At 05:00 every day. | Vercel's own example: daily at 5 am UTC | Yes |
30 23 * * * | At 23:30 every day. | A nightly job | Yes |
0 9 * * 1 | At 09:00 on Mondays. | Weekly, on Mondays | Yes |
0 0 1 * * | At 00:00 on the 1st of every month. | Monthly | Yes |
0 * * * * | Every hour, on the hour. | Hourly | No, paid plans only |
*/10 * * * * | Every 10 minutes. | Every 10 minutes | No, paid plans only |
0 9 * * MON | At 09:00 on Mondays. | Names aren't accepted | No, rewrite needed |
On a paid plan, the schedule pages have copy-paste vercel.json snippets for every 5 minutes, every hour and more.
Limits on each plan
| Plan | Cron jobs per project | Most often | Timing |
|---|---|---|---|
| Hobby | 100 | Once a day | Within the hour (an 08:00 job runs 08:00–08:59) |
| Pro | 100 | Once a minute | Within the minute |
| Enterprise | 100 | Once a minute | Within the minute |
On Hobby, an expression that would run more than once a day, like 0 * * * * or */30 * * * *, fails the deployment with "Hobby accounts are limited to daily cron jobs. This cron expression would run more than once per day." Daily jobs are spread across their hour to balance load, so 0 8 * * * can start at any point between 08:00:00 and 08:59:59. On Pro and Enterprise, 5 8 * * * starts between 08:05:00 and 08:05:59.
Cron jobs are included on every plan. Each run is a function invocation, so the usual function usage, pricing and duration limits (maxDuration) apply.
Secure the route with CRON_SECRET
A cron path is a public URL, so anyone can call it. Add an environment variable named CRON_SECRET to the project, a random string of at least 16 characters, and Vercel sends it as Authorization: Bearer <secret>with every invocation. Reject requests that don't carry it:
export function GET(request: Request) {
const auth = request.headers.get("authorization");
if (!process.env.CRON_SECRET || auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
// ... the job itself ...
return Response.json({ ok: true });
}Missed runs, duplicate runs and overlaps
Vercel doesn't retry a cron job that fails. Delivery is best effort: a transient network error can stop a run from reaching your function, and then there's no log for it either. The same scheduled run can also occasionally be delivered twice.
If a job takes longer than the gap between runs, the next run can start while the last is still going. Make jobs idempotent ("set status to active", not "add 10 credits"), have each run process everything outstanding since the last successful one, and use a lock, such as a Redis lock, when two copies must never run at once. A new deployment doesn't interrupt a run that's already going.
Don't count on every run
Several schedules on one path
Two entries can share a path. Each request carries an x-vercel-cron-schedule header holding the expression that triggered it, so the function can tell a daily full sync from a 5-minute incremental one (the 5-minute entry needs a paid plan):
{
"crons": [
{ "path": "/api/sync", "schedule": "*/5 * * * *" },
{ "path": "/api/sync", "schedule": "0 0 * * *" }
]
}Testing a cron job
vercel dev and next devdon't run cron schedules. To test, request the path yourself, for example http://localhost:3000/api/daily-report, with the Authorization header if you check CRON_SECRET.
Your browser follows redirects, but cron invocations don't: a 3xx response ends the run. A path that doesn't exist returns a 404 and still counts as an invocation. Check what each run returned with View Logson the Cron Jobs settings page, which opens the runtime logs filtered to the job's path. Redirects and cached responses don't appear there.