Vercel Cron Jobs: Limits, Timing, and Monitoring
Contents
A Vercel cron job is an entry in the crons array of vercel.json with a path and a five-field schedule; at each scheduled time, Vercel sends an HTTP GET request to that path on your production deployment. Schedules are always UTC, preview deployments are never invoked, and a failed run is not retried. Every plan allows 100 cron jobs per project, but Hobby runs each job at most once a day, at any point within the scheduled hour, while Pro and Enterprise run as often as every minute, within the scheduled minute. Vercel's behavior below was checked against vercel.com/docs on October 25, 2026.
How Vercel Cron Jobs Work#
Each entry names a path in your project and a cron expression:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{
"path": "/api/cron/nightly-report",
"schedule": "0 3 * * *"
}
]
}- A GET request to production. "To trigger a cron job, Vercel makes an HTTP GET request to your project's production deployment URL, using the
path" (cron jobs), and only "for production deployments and not for preview deployments" (quickstart). The path must start with/(cronsin vercel.json). In Next.js, point it at a Route Handler, which Vercel notes works even in a Pages Router app. - Identifying headers. Every request carries the user agent
vercel-cron/1.0and anx-vercel-cron-scheduleheader holding the expression that fired, so two entries can share a path and the handler can tell them apart. Anyone can send those headers; onlyCRON_SECRET, below, proves a request came from Vercel. - Changes need a deployment. Edit or remove the entry, then redeploy. Disable Cron Jobs under Settings → Cron Jobs stays in effect across promotions and Instant Rollbacks, disabled jobs still count toward the limit, and an Instant Rollback restores the cron definitions of the deployment you roll back to (managing cron jobs). A new deployment doesn't interrupt a run in progress.
- Nothing fires locally.
vercel devandnext devhave no scheduler; request the path yourself.
Cron Expression Rules#
Vercel accepts five numeric fields: minute (0-59), hour (0-23), day of month (1-31), month (1-12), and day of week (0-6, Sunday is 0). Its listed limitations:
- No names: "Cron jobs on Vercel do not support alternative expressions like
MON,SUN,JAN, orDEC." - One day field at most: "When one has a value, the other must be
*." - "The timezone is always UTC." With no time zone setting, a job meant for 09:00 in New York shifts by an hour of local time when daylight saving time starts or ends.
Plan Limits and Timing Precision#
Checked October 25, 2026, from cron usage and pricing and function limits:
| Hobby | Pro | Enterprise | |
|---|---|---|---|
| Cron jobs per project | 100 | 100 | 100 |
| Most frequent schedule | Once per day | Once per minute | Once per minute |
| When a run fires | Any time within the scheduled hour | Within the scheduled minute | Within the scheduled minute |
| Function maximum duration (Fluid compute) | 300 seconds, default and maximum | 300 default, 800 maximum, 1,800 in beta | Same as Pro |
- Hobby frequency. An expression that runs more than once a day fails the deployment: "Hobby accounts are limited to daily cron jobs. This cron expression would run more than once per day."
- Hobby timing. "Vercel may invoke these cron jobs at any point within the specified hour to help distribute load across all accounts", so
0 8 * * *runs anywhere from 08:00:00 to 08:59:59. The window is the hour, not the minute: for30 8 * * *, any point within the hour includes 08:05. On Hobby, write minute0. - Older limits. Answers citing 2 cron jobs on Hobby and 40 on Pro describe per-team limits Vercel removed on January 20, 2026.
- Duration. "The duration limits for Cron jobs are identical to those of Vercel Functions." The figures above apply with Fluid compute, on by default for projects created since April 23, 2025. Above 800 seconds, Pro and Enterprise set
maxDurationper function, on supported runtimes only (extended max duration).
Cloudflare Cron Triggers and cron jobs on Render, Railway, and Fly.io cover the limits on those platforms.
Secure the Endpoint With CRON_SECRET#
Add an environment variable named CRON_SECRET; Vercel recommends a random string of at least 16 characters. "The value of the variable will be automatically sent as an Authorization header when Vercel invokes your cron job", with a Bearer prefix (securing cron jobs). The handler rejects any request whose header doesn't match, and every request when the variable is missing. Without that check, anyone who finds the URL can run the job.
Two details can turn every invocation into a 401. The troubleshooting guide warns against newlines and special characters in the value. And environment variable changes "only apply to new deployments" (environment variables), so redeploy production after adding or rotating the secret.
Timeouts, Retries, Overlaps, and Duplicates#
A timeout ends the run without warning. A function that doesn't finish within its maximum duration is terminated with a 504 FUNCTION_INVOCATION_TIMEOUT. No code after that point runs, including code that would report the failure. In a Next.js App Router route, set the limit with export const maxDuration = 300; other frameworks set it in their adapter config or the functions object of vercel.json (configuring maximum duration).
Failures aren't retried. "Vercel will not retry an invocation if a cron job fails." The next attempt is the next scheduled run, which on Hobby is tomorrow.
Delivery is best effort. Transient network errors occasionally keep a request from reaching your function, and then "your function does not execute, and no runtime log is created for that scheduled run." Delivery "can also occasionally invoke the same scheduled run more than once" (delivery and idempotency). Vercel's advice is idempotent, reconciliation-based jobs: each run processes everything outstanding since the last successful run, so a duplicate does nothing and the run after a missed one catches up. Idempotent scheduled jobs shows how to claim a period, keep a watermark, and key side effects.
Runs can overlap. "If your cron job runs longer than the interval between invocations, Vercel can trigger a second instance while the first is still running." Vercel suggests a lock, such as a Redis distributed lock, and because duplicates happen too, idempotency alongside it.
Why a Vercel Cron Job Isn't Running#
| Cause | What you see | Fix |
|---|---|---|
| Checking a preview deployment | No invocations | Cron jobs run only on production; request the path yourself to test a preview |
| A Hobby schedule that runs more than daily | The deployment fails | Run daily, or move to Pro |
| Cron jobs disabled | Settings → Cron Jobs shows them disabled | Re-enable them |
A redirect: trailingSlash, a domain redirect, or auth middleware |
A 3xx, then nothing; no runtime log | Point the path at the final URL; add the trailing slash; exclude the cron path from login redirects |
| A cached response | No runtime log | Keep the route dynamic |
CRON_SECRET with a newline or special character, or set after the deploy |
401 | Use a plain random string, then redeploy |
| A path that doesn't exist | 404; Vercel still sends the request | Match the path to a route in the Deployment Summary |
| A custom firewall rule | The request is blocked | Exempt the cron requests |
| A dropped delivery | Nothing at all for that time | Nothing to fix in your code; catch it from outside Vercel |
The troubleshooting guide covers most rows. "Cron jobs do not follow redirects", so a middleware (proxy.ts since Next.js 16) that sends unauthenticated requests to a login page ends the run too. On caching: since Next.js 15, GET Route Handlers are uncached by default (route.js), and with Cache Components, reading request.headers, as the secret check does, stops prerendering (Route Handlers). A route is cached only if you opt in, for example with export const dynamic = 'force-static'.
Find a Run in the Logs#
View Logs next to a job on Settings → Cron Jobs opens runtime logs filtered to requestPath:/api/cron/nightly-report. Redirected and cached responses don't appear, and neither do dropped deliveries. Runtime logs are kept for 1 hour on Hobby, 1 day on Pro, and 3 days on Enterprise, or 30 days with Observability Plus (runtime logs), so a Hobby job's 03:00 log is gone before most people look.
To run a deployed job now, use vercel crons run /api/cron/nightly-report (beta), which reads the cron definitions from your production deployment, or run it from the deployment summary in the dashboard.
Report Each Run From the Route Handler#
An outside monitor that knows the schedule covers what Vercel's logs can't: the job reports when it starts and finishes, and silence past the expected time is the alert. There's no CronRadar SDK you can install for Node.js today (the cronradar npm package is temporarily unavailable) and no auto-discovery of vercel.json crons, so each cron path reports with its own HTTP check-ins. The pattern matches Node.js cron job monitoring, adapted to a function with a hard time limit.
This route checks the secret, reports start, runs the job under its own deadline, reports complete or fail, and returns 200 or 500. It is written for Next.js 16 (16.4.3 on npm, checked October 25, 2026) and works unchanged on Next.js 15:
// app/api/cron/nightly-report/route.ts
import type { NextRequest } from 'next/server'
import { buildNightlyReport } from '@/lib/nightly-report'
// Vercel ends the invocation at this limit: the default on every plan, the maximum on Hobby.
export const maxDuration = 300
const MONITOR_KEY = 'nightly-report'
const SCHEDULE = '0 3 * * *' // the same string as this path's entry in vercel.json
const GRACE_SECONDS = '3900' // Hobby: a run can start any time in the 03:00 hour
// Stop waiting 30 seconds early, so the reports (5 seconds each at most) still fit.
const WORK_BUDGET_MS = (maxDuration - 30) * 1000
async function report(event: 'start' | 'complete' | 'fail', params: Record<string, string>) {
const apiKey = process.env.CRONRADAR_API_KEY
if (!apiKey) return
const url = `https://cron.life/api/v1/ping/${MONITOR_KEY}/${event}?${new URLSearchParams(params)}`
try {
const response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Basic ${btoa(`${apiKey}:`)}` },
signal: AbortSignal.timeout(5000),
})
if (!response.ok) console.error(`CronRadar ${event} report returned ${response.status}`)
} catch (error) {
console.error(`CronRadar ${event} report failed:`, error)
}
}
// Settles with the work, or rejects at the deadline even if the work ignores the signal.
function raceDeadline<T>(work: Promise<T>, deadline: AbortSignal): Promise<T> {
const expired = new Promise<never>((_, reject) => {
deadline.addEventListener('abort', () => reject(deadline.reason), { once: true })
})
return Promise.race([work, expired])
}
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 params = { schedule: SCHEDULE, gracePeriod: GRACE_SECONDS, run_id: crypto.randomUUID() }
await report('start', params)
const deadline = AbortSignal.timeout(WORK_BUDGET_MS)
try {
await raceDeadline(buildNightlyReport({ signal: deadline }), deadline)
} catch (error) {
// Keep the message generic: error text can carry data that must not leave the app.
const message = deadline.aborted
? `Stopped at the ${WORK_BUDGET_MS / 1000}-second deadline`
: `${MONITOR_KEY} threw ${error instanceof Error ? error.name : 'a non-Error value'}`
console.error(message, error)
await report('fail', { ...params, message })
return Response.json({ ok: false }, { status: 500 })
}
await report('complete', params)
return Response.json({ ok: true })
}buildNightlyReport is your job: an async function that takes { signal } and passes the signal on. Monitoring can't change the outcome: each report gives up after 5 seconds, its errors are logged and ignored, the response status comes from the job alone, and without CRONRADAR_API_KEY the reports are skipped. Every check-in carries the schedule, so the first run creates the monitor and a deleted monitor comes back on the next run. A random run_id per invocation keeps an overlapping or duplicate run separate. In another framework, the same logic goes in its function handler.
Await the Reports Instead of Firing and Forgetting#
Vercel's scheduler is the route's only caller, so holding the response for up to 5 seconds per report costs nothing anyone notices. A fetch that is neither awaited nor handed to after() (stable since Next.js 15.1) or waitUntil() from @vercel/functions may not finish once the response is sent; keeping an invocation alive after its response is what those APIs are for.
They don't buy extra time, though. after() "will run for the platform's default or configured max duration of your route", and promises passed to waitUntil() "will have the same timeout as the function itself." Vercel recommends after() over waitUntil() on Next.js 15.1 and later, for work that shouldn't delay a user's response. A cron route has no user waiting, so awaiting is simpler and guarantees each report is sent before the response.
A Deadline Below maxDuration Turns a Timeout Into a Fail#
When Vercel ends a run at maxDuration, nothing reports fail; CronRadar has only the start. CronRadar flags a start with no finish as stuck when the job's next scheduled time plus the grace period passes with no further check-in, and the next run's start resets that window. A nightly job that times out every night still starts every night, so it is never flagged as stuck.
The deadline closes that gap. At maxDuration - 30 seconds, the handler stops waiting, reports fail, which alerts immediately, and returns 500 while the function still has time. The worst case is 5 seconds for start, 270 for the work, and 5 for fail: 280 of 300. Raise maxDuration on Pro and the budget follows. The race guarantees the report; the signal is what stops the work, so pass it to every fetch and database call that accepts one. Cron job timeouts covers finding the call that hangs and giving it its own limit.
One Monitor per Cron Path#
Give each vercel.json entry its own monitor key and send its schedule string unchanged. Vercel's rules mean it reads the same to CronRadar: both evaluate in UTC, Sunday is 0 on both, and CronRadar's one difference from Linux cron, expecting a run only on dates that match both day fields (schedule syntax), never comes up because Vercel forbids restricting both. If two entries share a path, pick the key and schedule from the x-vercel-cron-schedule header.
Choosing the Grace Period#
The start report comes first, so the grace period has to cover only how late Vercel starts the function (grace periods explains why). Vercel documents that window per plan:
- Hobby: 3,900 seconds, with minute 0. The scheduled hour plus 5 minutes for a cold start and the report itself. Minute 0 matters to CronRadar too: every check-in moves the expected time to the job's next scheduled occurrence, so a start at 03:05 for
30 3 * * *would leave 03:30 today as the next expected run, and that run never arrives. - Pro and Enterprise, hourly or less often: 300 seconds. Runs start within the scheduled minute; 5 minutes covers that and a cold start.
- Every few minutes: less than the interval. With a 10-minute schedule and a 15-minute grace, the next run checks in before a missed one is flagged; 120 seconds flags each one. GitHub Actions scheduled workflows works through the same trade-off for a hosted scheduler with no documented delay bound.
schedule and gracePeriod apply only when a check-in creates the monitor. To change them later, including after you edit vercel.json, edit the monitor's settings.
Set It Up and Test It#
- Create a CronRadar application for production and add its key as
CRONRADAR_API_KEYin the Vercel project's Production environment only, since cron jobs never run elsewhere. Give any environment you test in its own application and key. - Redeploy production so the variable reaches it.
- Run the job locally with
curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/nightly-report, without the production key. - Run it in production with
vercel crons run, confirm the run appears in CronRadar, and send a test alert to whoever will act on it.
From then on, a dropped delivery, disabled cron jobs, or an entry removed by a deploy sends no start, so the monitor turns warning at the expected time and critical once it is past the grace period and at least two minutes late. A thrown error or the deadline sends fail. Alerts go to email, Slack, a webhook, Discord, or PagerDuty.
One gap remains: a monitor created by its first check-in can't flag a job that never runs, such as a cron path only ever deployed to previews. Create the monitor in the CronRadar dashboard before deploying, and it stays pending until the first run; CronRadar sends a never-executed alert if the first expected run plus the grace period passes with no report.
Frequently Asked Questions#
Why isn't my Vercel cron job running?#
Make sure you're checking a production deployment, since preview deployments are never invoked, and that cron jobs aren't disabled under Settings → Cron Jobs. Then read the runtime logs for the path: a 3xx means a redirect ended the run, a 401 means the secret didn't match, and a 404 means the path doesn't match a route. On Hobby, a run can arrive anywhere in the scheduled hour, and on any plan Vercel occasionally drops a delivery without leaving a log.
How often can Vercel cron jobs run?#
On Hobby, once a day at most; a more frequent expression fails the deployment. On Pro and Enterprise, as often as once a minute. Every plan allows 100 cron jobs per project (checked October 25, 2026).
Does Vercel retry failed cron jobs?#
No. The next attempt is the next scheduled run. Because a run can also occasionally be skipped or delivered twice, make the job idempotent and have each run catch up on everything since the last success.
How do I secure a Vercel cron endpoint?#
Set a CRON_SECRET environment variable to a random string of at least 16 characters and redeploy. Vercel sends it as Authorization: Bearer followed by the value; return 401 unless the header matches exactly, and for every request if the variable is missing. Don't rely on the vercel-cron/1.0 user agent, which anyone can send.
Do Vercel cron jobs run on preview deployments?#
No, only on production deployments. To test a preview, request the cron path yourself with the Authorization header. To test production without waiting for the schedule, use vercel crons run or the deployment summary in the dashboard.
Put this guide to work
Connect your Node.js job to monitoring
Use the working HTTP reporting path in the guide while the SDK is unavailable. This requires explicit instrumentation and does not discover jobs automatically.
The Node.js SDK is temporarily unavailable: the published package cannot load because it is missing a required file. Use manual HTTP reporting below. This setup does not discover jobs automatically.