To monitor Vercel cron jobs, give each entry of your vercel.json crons array its own heartbeat URL, and have the function ping it only after the work succeeded. When a run fails, times out, is redirected or never reaches your function, no ping arrives and you get an alert. The trap is reading Vercel's logs as proof that the job ran: a failed run is not retried, a missed delivery leaves no log line at all, and a route can answer 200 without doing its work.
I'm Léo, I build Hyperping, and this guide covers the ways Vercel cron jobs fail without anyone noticing, what Vercel shows you, and the setup I use with Hyperping healthchecks. Every Vercel fact below was checked against the Vercel docs on October 8, 2026. I tested the route handler on Next.js 16.4.0 with next build and next start, and under plain Node 20.19.5, against a local HTTP listener standing in for Hyperping. I ran vercel build (CLI 63.1.0) locally to check the generated cron config, but deployed nothing, so Vercel's scheduler itself was not part of the test.
Key takeaways
- Vercel calls your cron path with an HTTP GET, in UTC, on the production deployment only. It does not retry a failed run, does not follow redirects, and calls delivery "best effort".
- On Hobby, a cron job runs at most once a day and can fire anywhere in the scheduled hour. Size the heartbeat's grace period for that hour plus the run time.
- A 200 in the logs proves little: a GET route that never reads the request can be prerendered at build time and served without running, and work scheduled with
after()fails after the 200 is sent. - Ping a heartbeat at the end of the route, after the work, only on success. Wrap the ping in a timeout and a try/catch so a slow monitor never breaks the job.
- Use one Hyperping healthcheck per cron entry, in cron mode, with the same expression and the timezone set to UTC.
How Vercel cron jobs fail silently
A Vercel cron job is a line in vercel.json that points a schedule at a path:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{ "path": "/api/cron/daily-digest", "schedule": "0 5 * * *" },
{ "path": "/api/cron/sync-orders", "schedule": "*/15 * * * *" },
{ "path": "/api/cron/weekly-report", "schedule": "0 8 * * 1" }
]
}According to the Cron Jobs docs, "Vercel makes an HTTP GET request to your project's production deployment URL, using the path", with vercel-cron/1.0 as the user agent and an x-vercel-cron-schedule header holding the expression. Everything else is your function's job, and Vercel only records what the function returned.
Crons only exist on the current production deployment
The quickstart is explicit: "Vercel invokes cron jobs only for production deployments and not for preview deployments". A cron route that works on a preview URL has never been called by the scheduler.
The cron list also lives in each deployment. Remove an entry from vercel.json, or merge a branch that predates it, and the next production deploy deletes the job. An Instant Rollback brings back the crons "of the rolled-back deployment", and the Disable Cron Jobs button in settings stops all of them. Rename the route file without updating path and the docs say the job hits a 404, but "Vercel still executes your cron job".
Failed runs are not retried, and some never arrive
The error handling section is one sentence: "Vercel will not retry an invocation if a cron job fails." A daily job that throws at 05:00 waits until 05:00 the next day.
Delivery itself is not guaranteed either. From the idempotency section: "Cron job delivery is best effort. Most invocations run as scheduled, but occasional transient network errors can prevent a request from reaching your function. In those cases, your function does not execute, and no runtime log is created for that scheduled run." Nothing inside Vercel can show you that one.
Redirects and auth layers end the run
"Cron jobs do not follow redirects. When a cron-triggered endpoint returns a 3xx redirect status code, the job completes without further requests." The usual culprits are a proxy.ts (called middleware.ts before Next.js 16) that sends anonymous visitors to /login, and the trailingSlash option, which Vercel's troubleshooting guide calls out. I built a proxy that redirects requests without a session cookie: curl on a route under it got 307 -> /login, which is exactly what the scheduler would receive. Exclude the cron routes in the matcher and let them check CRON_SECRET themselves:
// proxy.ts
export const config = {
matcher: ['/((?!api/cron|_next/static|_next/image|favicon.ico).*)'],
};The other common status is 401 from your own secret check. CRON_SECRET set only for Preview, added after the last production deploy ("Any change you make to environment variables are not applied to previous deployments"), or pasted with a trailing newline, which the troubleshooting guide also warns about. Every run then answers 401, and only the logs know.
A static route never runs the job
Next.js can prerender a GET route handler at build time when it never reads the request. In my next build on Next.js 16.4.0, a route that only logged a line and returned JSON was marked ○ (Static): the log line printed twice during the build, then never again, however many times I called the route with next start. Each call got a 200 from the stored response. Vercel's troubleshooting guide notes that cached responses do not show up in the cron logs.
A route that reads request.headers, like the CRON_SECRET check below, is marked ƒ (Dynamic) and runs on every call. Check that symbol for every cron route in your build output.
Timeouts kill long runs
The duration limits are those of any Vercel Function. With Fluid compute:
| Plan | Default | Maximum |
|---|---|---|
| Hobby | 300 s | 300 s |
| Pro | 300 s | 800 s (1,800 s in beta) |
| Enterprise | 300 s | 800 s (1,800 s in beta) |
A function that runs past its maxDuration is terminated with a 504 FUNCTION_INVOCATION_TIMEOUT, and with no retry, the work stops halfway until the next scheduled run. A sync job that took 4 minutes at launch and 6 minutes a year later starts failing every night on Hobby without any code change.
A 200 that comes before the work
after() in Next.js and waitUntil() from @vercel/functions let a function keep working after it has responded. I scheduled a job that throws inside after(): the route answered 200 {"ok":true}, and the error ("An error occurred in a function passed to after()") only appeared in the server log afterwards. For a cron job, do the work before returning, so the status code and the heartbeat both reflect the result.
Hobby fires anywhere in the hour, and everything is UTC
The usage page lists 100 cron jobs per project on every plan, but Hobby is limited to "Once per day" with "Per-hour" precision: "a cron job configured as 0 1 * * * (every day at 1 am) will trigger anywhere between 1:00 am and 1:59 am". Expressions that run more often fail the deployment. Pro and Enterprise run within the scheduled minute.
The cron expression limitations add three rules: no names like MON or JAN, day of month and day of week cannot both be set, and "The timezone is always UTC". There is no timezone field: vercel build rejected one with "crons[0] should NOT have additional property timezone". It did accept 0 8 * * MON without complaint, so do not count on the build to catch the name rule. A job meant for 7:00 in Paris drifts by an hour twice a year.
Overlapping and duplicate runs
"Cron delivery can also occasionally invoke the same scheduled run more than once", and a job that runs longer than its interval gets a second instance started on top of it. The docs recommend a lock (Redis, for example) and idempotent work: "Set user status to active" is safe to run twice, "Increment user credit by 10" is not.
How to check Vercel cron jobs with Vercel's own tools
Vercel gives you a list, a manual trigger and logs:
- The project's Settings > Cron Jobs page lists every job with its path and schedule. View Logs opens the runtime logs filtered on
requestPath:/api/cron/daily-digest, where you see the status code of each invocation. - The deployment summary shows the cron jobs registered by that deployment and lets you run one.
- The CLI has
vercel crons lsandvercel crons run /api/cron/daily-digest(beta), which triggers the job deployed to production. vercel logs --environment production --status-code 5xx --since 24hlists recent errors. The runtime logs can also be filtered withrequestTypeset tocron.
Watch the retention: runtime logs are kept 1 hour on Hobby, 1 day on Pro and 3 days on Enterprise, 30 days with Observability Plus. On Hobby, a daily job's log line is gone long before anyone looks. Log drains, on Pro and Enterprise, forward them to a tool that can store them and alert on errors.
All of these record invocations. A run Vercel never delivered, a cron removed by the last deploy, or a job your plan never scheduled leaves no line to alert on. That is the case an external heartbeat covers.
How to monitor Vercel cron jobs with Hyperping
A Hyperping healthcheck is a secret URL that expects a request on a schedule. When the request does not arrive by the expected time plus a grace period, it opens an incident and alerts you, then resolves on the next successful ping. Healthchecks are included on every plan, Free included.
1. Create one healthcheck per cron job, in UTC
In Hyperping, open Healthchecks, click Create healthcheck, name it after the route, and pick Cron. Copy the schedule from vercel.json and set the timezone to UTC:
vercel.json schedule |
Runs (UTC) | Plan | Generator page |
|---|---|---|---|
*/15 * * * * |
Every 15 minutes | Pro, Enterprise | every 15 minutes |
0 * * * * |
Every hour, on the hour | Pro, Enterprise | every hour |
0 0 * * * |
Every day at midnight | All plans | every day |
0 5 * * * |
Every day at 05:00 | All plans | |
0 8 * * 1 |
Mondays at 08:00 | All plans |
If two entries share a path with different schedules, give each its own healthcheck and pick the URL from the x-vercel-cron-schedule header. Each healthcheck gives you a URL like https://hc.hyperping.io/tok_.... Vercel's scheduler has no process of its own to watch, so these per-job healthchecks are also how you find out that Vercel stopped calling you.
2. Store the ping URL in a Vercel environment variable
Add it in Settings > Environment Variables for the Production environment, or with the CLI:
printf '%s' 'https://hc.hyperping.io/tok_your_daily_digest_token' \
| vercel env add HYPERPING_DAILY_DIGEST_URL productionprintf '%s' keeps a trailing newline out of the value. Then redeploy: the running deployment never sees a variable added after it was built.
3. Ping on success only, with /start for duration
// app/api/cron/daily-digest/route.ts
import type { NextRequest } from 'next/server';
import { sendDailyDigest } from '@/lib/digest';
// Hobby allows up to 300 seconds, Pro and Enterprise up to 800.
export const maxDuration = 300;
async function ping(url: string | undefined) {
if (!url) return;
try {
await fetch(url, { signal: AbortSignal.timeout(5000) });
} catch (error) {
console.error('Hyperping ping failed:', error);
}
}
export async function GET(request: NextRequest) {
const cronSecret = process.env.CRON_SECRET;
if (!cronSecret || request.headers.get('authorization') !== `Bearer ${cronSecret}`) {
return new Response('Unauthorized', { status: 401 });
}
const pingUrl = process.env.HYPERPING_DAILY_DIGEST_URL;
await ping(pingUrl && `${pingUrl}/start`);
try {
await sendDailyDigest();
} catch (error) {
console.error('daily-digest failed:', error);
return new Response('Job failed', { status: 500 });
}
await ping(pingUrl);
return Response.json({ ok: true });
}The secret check comes first, as in Vercel's example, so a stray visitor never pings. /start opens a run in Hyperping without moving the deadline. The plain URL closes it, records the duration and sets the next deadline. A failed job returns 500 so it shows as an error in Vercel's logs, and sends nothing after /start.
Here is what I observed with next build and next start on Next.js 16.4.0, with the listener in place of hc.hyperping.io:
- No
Authorizationheader, a wrong one, or noCRON_SECRETset: 401, and no request reached the listener. - Correct header:
GET /tok_.../start, thenGET /tok_...about 200 ms later, both withnodeas the user agent, and a 200. - A job that throws: only the
/startrequest, and a 500. - A ping endpoint that never answers: each ping gave up after 5 seconds with a
TimeoutError, and the route still returned 200 after 10.2 seconds.
Outside Next.js, the same GET function works in a plain api/cron/daily-digest.ts, since Vercel Functions accept the Web Request and Response signature. Type the parameter as Request instead of NextRequest, import the job with a relative path, drop the maxDuration export and set the duration in vercel.json:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [{ "path": "/api/cron/daily-digest", "schedule": "0 5 * * *" }],
"functions": {
"api/cron/daily-digest.ts": { "maxDuration": 300 }
}
}4. Size the grace period to the run time and your plan
In cron mode, Hyperping expects the success ping by the scheduled time plus the grace period, and the success ping leaves at the end of the run. Since a function cannot run past maxDuration, that value bounds every successful run:
- On Pro and Enterprise, Vercel invokes the job within the scheduled minute. A grace period a few minutes above
maxDurationcovers any successful run: the default 10 minutes works for 300 seconds, use 20 minutes for 800. - On Hobby, add the hour Vercel may wait. A daily job at
0 5 * * *can start at 05:59 and finish at 06:04, so the grace period must be at least 65 minutes. I use 75. Simple mode set to every day with the same 75 minute grace period works too.
After a few runs, the healthcheck's ping history shows the time between each /start and success ping. If your jobs take seconds, a grace period of twice the longest run plus Vercel's delay is enough. Ping limits are not a concern here: even a Pro job every minute sends two pings a minute, under the 10 per minute allowed per healthcheck.
5. Route the alerts
A missed ping goes to every channel connected to the project at once: email and SMS to every member, Slack, Discord, Telegram, PagerDuty and Opsgenie. Healthchecks do not use escalation policies or on-call schedules, so if a failed billing sync has to wake someone, send the alert to PagerDuty or Opsgenie and let their rotation decide who gets paged.
Test it before you trust it
Deploy to production, then run vercel crons run /api/cron/daily-digest or use the run action in the deployment summary. In the healthcheck's last pings you should see a STARTED ping, then an OK ping with the duration, both GET with node as the user agent (what Node's built-in fetch sends). If nothing shows up, check the invocation in View Logs for a 307 or a 401.
Then check the failure path. Locally, put CRON_SECRET and the ping URL in .env.local, force the job to throw, run next build && next start, and call curl -H "Authorization: Bearer $CRON_SECRET" localhost:3000/api/cron/daily-digest: the healthcheck receives STARTED and no OK. To see the alert without waiting a day, switch the healthcheck to */5 * * * * with a 1 minute grace period, send it one ping with curl so the next deadline comes from that schedule, and wait. It goes down within about six minutes. Check the alert reached the right channel, then restore the real schedule and grace period and run the job again to close the incident.
The same pattern covers the other schedulers you may run next to Vercel: Cloudflare Workers cron triggers, GitHub Actions scheduled workflows and Kubernetes CronJobs. For a side by side of heartbeat tools, see the best cron job monitoring tools. If the route calls a model, monitoring scheduled AI agents shows how to ping only after the output passes a check.
FAQ
Why is my Vercel cron job not running? ▼
Vercel only invokes cron jobs on the production deployment, so a cron tested on a preview URL never fires. Then open Settings > Cron Jobs, click View Logs, and look at the status of each invocation: a 307 means a proxy or auth layer redirected the request, which Vercel does not follow, and a 401 usually means `CRON_SECRET` is missing from the production environment or was added without a redeploy. A route that `next build` marks as Static can also return a stored response without running your code.
Does Vercel retry failed cron jobs? ▼
No. The Vercel docs say "Vercel will not retry an invocation if a cron job fails." Delivery is also best effort: a transient network error can prevent a scheduled request from reaching your function, and in that case no runtime log is created. The next attempt is the next scheduled run.
What timezone do Vercel cron jobs use? ▼
Always UTC. There is no timezone option: `vercel build` 63.1.0 rejects a `timezone` key in a cron entry with "should NOT have additional property `timezone`". Convert your local time to UTC in `vercel.json`, and remember that a job at 05:00 UTC runs at 07:00 in Paris in summer and 06:00 in winter.
How precise are Vercel cron jobs on the Hobby plan? ▼
Hobby cron jobs can run at most once per day, and Vercel may invoke them at any point within the scheduled hour: `0 8 * * *` can fire anywhere between 08:00:00 and 08:59:59 UTC. Expressions that run more often fail the deployment. Pro and Enterprise cron jobs run within the scheduled minute and can run every minute.
How do I secure a Vercel cron job with CRON_SECRET? ▼
Add an environment variable named `CRON_SECRET` to the project, at least 16 random characters. Vercel sends it as `Authorization: Bearer <value>` on every cron invocation, and your route returns 401 when the header does not match. Environment variable changes only apply to new deployments, so redeploy after adding it.
How long can a Vercel cron job run? ▼
As long as any Vercel Function. With Fluid compute, Hobby functions get 300 seconds maximum, and Pro and Enterprise functions default to 300 seconds with a maximum of 800 (1,800 in beta). Raise it with `export const maxDuration` in a Next.js route. A run that hits the limit is terminated with a 504 `FUNCTION_INVOCATION_TIMEOUT` and is not retried.




