2026-10-27
Why is my Vercel cron job not running?
A Vercel cron runs only on the production deployment, in UTC. On Hobby it runs once a day, any time in that hour. Ping after the work so a skipped run still pages you.
A Vercel cron that never fires leaves the runtime log on the last invocation that did happen. Vercel triggers the job with an HTTP GET to the path in vercel.json, and only on the production deployment. A route you can open on a preview URL, or from next dev, is not that schedule. This page is the production schedule: which expressions deploy, what a 401 from CRON_SECRET means, where the logs are, and how a ping after the work turns a skipped slot into an alert.
A Linux crontab, a scheduled GitHub Actions workflow, an n8n workflow, and a Zapier Zap miss their slots for different reasons: crontab, GitHub Actions, n8n, and Zapier. Vercel schedules are always UTC. The daylight-saving post covers what a UTC cron does on November 1 and March 8, 2026.
What Vercel sends
The cron jobs page is the spec. Vercel makes an HTTP GET to your project's production deployment URL, using the path from vercel.json. The quickstart says those jobs are invoked for production deployments and not for preview deployments. The request's user agent is vercel-cron/1.0. It carries an x-vercel-cron-schedule header with the expression that fired, which is how one route tells two schedules apart when they share a path.
The path has to start with /. vercel.json limits that string to 512 characters and the schedule string to 256. The schedule is five fields: minute, hour, day of month, month, day of week. The timezone is always UTC. Named months and weekdays such as JAN, DEC, MON, and SUN are not supported. An expression cannot set both the day of month and the day of week. When one of those fields has a value, the other has to be *.
This entry is 05:00 UTC every day, which is once a day. The path is the route that exports GET.
{
"crons": [
{
"path": "/api/nightly",
"schedule": "0 5 * * *"
}
]
}Hobby runs once a day, somewhere in that hour
The usage and pricing table is the plan limit. Every plan allows 100 cron jobs per project. Hobby's minimum interval is once per day, and its precision is the hour, plus or minus 59 minutes. Pro and Enterprise can run once a minute, with per-minute precision. Cron jobs are included on every plan. Each run is a function invocation, so the function usage limits apply to it.
Hobby rejects a schedule that would run more than once a day, and the deployment fails. The documented error is: Hobby accounts are limited to daily cron jobs. This cron expression would run more than once per day. The examples on that page are 0 * * * * and */30 * * * *. The deployment that contains the expression does not become production, so the cron in it is never created. The previous production deployment, if you still have one, keeps the crons it was built with.
A Hobby job that did deploy still does not run at the minute you typed. Vercel may invoke it at any point in the specified hour. 0 1 * * * runs somewhere between 1:00 am and 1:59 am UTC. 0 8 * * * runs somewhere between 08:00:00 and 08:59:59. A look at the logs at 08:05 can be early. On Pro and Enterprise, the managing cron jobs page says every team that is not on Hobby is invoked within the scheduled minute. 5 8 * * * runs between 08:05:00 and 08:05:59.
Disabled cron jobs stay on the list and still count toward the 100. The Disable Cron Jobs switch is per project. It survives deployment promotions and Instant Rollbacks. Turning it on stops every cron until you turn it off again on that settings page. A redeploy does not turn them back on.
The vercel.json that is not on production
The quickstart says the build creates the cron job when the project deploys. vercel deploy --prod is a production deploy. A vercel.json on a branch, or a file you saved and have not deployed, is not the schedule Vercel invokes. Changing an expression, or removing one, is an edit to vercel.json or to the function's configuration, then a redeploy. Until that deploy is the production deployment, the old definitions are the ones that run.
Instant Rollback restores the cron definitions from the deployment you rolled back to. A rollback to a deployment that has no entry for this path stops the job. The Disable Cron Jobs setting is left as it was.
vercel crons run /api/nightly triggers a cron that is already on the production deployment. The command reads the deployed project, not the vercel.json in the working tree. A run you start that way shows that the route can answer. It does not show that the schedule will fire on its own.
The path has to answer the GET
Vercel sends GET. The route needs a GET handler. A path with no route still receives the request, and the response is a 404. The managing page says Vercel still executes the cron in that case. The work did not run. The log for that slot is the 404.
Cron jobs do not follow redirects. A 3xx response ends the invocation, and the destination is never requested. A redirect or a cached response is also left out of the cron logs, so the Cron Jobs page can look empty for a slot whose route redirected. A browser on localhost may follow that redirect. The production invocation will not. The schedule does not run under vercel dev or next dev. To test the handler, request the path yourself.
A 401 from CRON_SECRET
The managing page's recommended lock is an environment variable named CRON_SECRET, a random string of at least 16 characters. When that variable is set, Vercel sends it on the cron request as the Authorization header, with a Bearer prefix. The documented handler returns 401 when the variable is missing, or when the header is not exactly Bearer followed by the variable's value.
import type { NextRequest } from 'next/server';
export function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization');
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
return new Response('Unauthorized', {
status: 401,
});
}
return Response.json({ success: true });
}That 401 is the invocation. If the handler returns before the work, the work did not run. Vercel does not retry a failed cron. The next attempt is the next time the schedule matches. The function that runs is the one on the production deployment, so the CRON_SECRET that function can read is the one that has to match the header. A variable applies to the next deployment of the environment you selected, not to a deployment that is already current. A variable selected only for Preview is not on Production. A trailing newline, or a comparison that drops the Bearer prefix, fails the check above. The success JSON in that sample is not a finished job. Replace it with the work, and ping after the work, as in the handler further down. A 200 that leaves before the ping tells Vercel the cron completed and tells the monitor nothing.
Timeouts, errors, and a run Vercel will not retry
Cron duration limits are the function duration limits. The duration page is the table, and the managing page points at maxDuration for cron jobs. With fluid compute, which has been the default for new projects since April 23, 2025, Hobby functions default to 300 seconds and cannot be set higher. Pro and Enterprise default to 300 seconds, with a maximum of 800 seconds, and an extended maximum of 1800 seconds in beta on the runtimes that page lists. When the invocation runs longer than the duration set for that function, Vercel terminates it.
A failed cron is not retried. That includes a timeout, a 500, and the 401 above. Error logs are the View Log button on the Cron Jobs settings page. If the work runs longer than the gap before the next slot, another invocation can start while the first is still going. The managing page's remedies are a shorter job, a maxDuration that stops a long one, a wider schedule, and work that is safe to run twice. Delivery is also best effort. A transient network error can stop the request from reaching the function, and that slot then has no runtime log. The same scheduled run can also arrive more than once. Idempotent work is the documented response to both.
How to see whether the slot ran
Open the project in the Vercel dashboard, then Settings, then Cron Jobs. The list includes disabled jobs. View Logs on a row opens the runtime logs with a requestPath filter for that path. Cron invocations are function invocations, and the user agent on them is vercel-cron/1.0.
- A log line for the slot means Vercel sent the GET. Read the status. 401 is the CRON_SECRET check. 404 is a path with no route. A timeout or a 5xx is a function that started and failed. None of those are retried.
- No log line can mean the request never arrived. Best-effort delivery documents that case, and it writes no runtime log. A redirect or a cached response is also omitted from these logs.
- No log line can also mean the job was not scheduled. The project has Disable Cron Jobs on. The expression is only in a preview deployment, or only in a local vercel.json. Instant Rollback restored a deployment that does not have this path. On Hobby, the expression runs more than once a day and the deployment failed before the cron existed.
- The hour is not over yet. On Hobby, 0 8 * * * is still on time at 08:50. Wait until 09:00 UTC before you call that slot missed.
A short pass before you rewrite the route
- The production deployment is the one you mean. Preview deployments do not invoke crons. vercel deploy --prod publishes this vercel.json as production. Promoting an older deployment makes that deployment's crons the ones that run.
- Settings, Cron Jobs, lists the path. Disable Cron Jobs is off. The schedule is five UTC fields. Day of month and day of week are not both set. The path starts with /.
- On Hobby the expression runs once a day, and the deployment succeeded. 0 * * * * fails the deploy. The invocation may fall anywhere in the named hour.
- On Pro or Enterprise the invocation is inside the scheduled minute. The project is within the 100 cron jobs allowed on every plan, counting the disabled ones.
- The route exports GET. Requesting it on the production host without a redirect returns the handler, not a 404 and not a 3xx.
- If the handler checks CRON_SECRET, the production function has that variable, and Authorization is Bearer plus that exact value. A 401 in View Logs is this check.
- The function finishes inside its maxDuration. A timeout is in the logs, and it is not retried.
- vercel crons run uses the deployed path, after a production deploy. It proves the handler. The schedule is a separate question, answered by the next slot's log line.
Ping after the work
The checks above are what you do once you already think a slot was missed. A disabled project, a Hobby expression that never deployed, a rollback to a deployment without this path, and a best-effort miss with no runtime log all share a property: the handler does not finish, so it cannot report the skip. The check that still fires lives outside the function. A monitor expects a ping after the work, and the missing ping is the alert.
Ping after the work, not before it. The ping API is specific: POST or GET /ping/<token>/start records the start, and it does not reset the deadline and does not clear a down monitor. The success POST is the heartbeat. /fail marks the monitor down on the error path, when you want the alert as soon as the handler throws, instead of at the deadline. The dead-man's switch post is the short version of this shape.
The monitor page copies the handler below. NIGHTSHIFT_PING_URL is the monitor's ping URL, set on the production deployment, because that is the deployment the cron calls. The token in the URL is the credential. Do not commit it. tokens, cost_usd, and duration_ms are optional. The zeros below are still a success, and they still move the deadline. Free records the numbers. Pro and Team are the plans that also alert when a loop spends too much while the pings are still arriving. doTheWork stands in for the job.
export async function GET() {
const ping = process.env.NIGHTSHIFT_PING_URL!;
try {
await doTheWork();
await fetch(ping, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ tokens: 0, cost_usd: 0 }),
});
return Response.json({ ok: true });
} catch (error) {
await fetch(ping + "/fail", { method: "POST" });
throw error;
}
}Put the CRON_SECRET check first, and return the 401 before doTheWork. A cron Vercel never calls never enters this function, so neither fetch runs. The monitor still has a deadline. The schedules page says the next deadline is the next cron slot plus the grace period you set, and the hosted checker looks once a minute. A silence is noticed within about a minute after that deadline.
Create the monitor with the same five fields, and set its timezone to UTC. Vercel does not evaluate the expression in any other zone. On Hobby, 0 5 * * * can ping as late as 05:59:59. Grace of 60 minutes puts that deadline at 06:00 UTC, so a ping anywhere in the documented hour still counts. A shorter grace marks the monitor down while Vercel is still inside the hour. On Pro and Enterprise the invocation is inside the scheduled minute, so grace has to cover the rest of that minute plus the time the function needs to finish and ping. A function with a 300 second maxDuration can still be working five minutes after the minute starts. An hourly Nightshift monitor will not match a Hobby cron, because Hobby will not deploy an hourly expression.
Two deliveries of one slot send two success pings, and the monitor stays up. Vercel's managing page tells you to make the work idempotent for that reason. A heartbeat will not notice the duplicate. On Pro and Team, a hard max of 1 run per hour alerts when a daily job succeeds twice inside an hour, including a vercel crons run you fired yourself. The baseline alert needs a window of at least 10 runs, so it will not flag a daily duplicate. The runaway limits page is the rule. Free records tokens and cost and does not send those alerts.
The ping URL is on the monitor page after you sign up. The same silence check for a shell crontab is the crontab post.