# Vercel Cron Jobs: Limits, Timing, and Monitoring

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/vercel-cron-jobs

> How Vercel cron jobs work: vercel.json syntax, plan limits and timing, CRON_SECRET, function timeouts, missed and duplicate runs, and how to get alerted.

**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:

```json
{
  "$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](https://vercel.com/docs/cron-jobs)), and only "for production deployments and not for preview deployments" ([quickstart](https://vercel.com/docs/cron-jobs/quickstart)). The path must start with `/` ([`crons` in vercel.json](https://vercel.com/docs/project-configuration/vercel-json#crons)). 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.0` and an `x-vercel-cron-schedule` header holding the expression that fired, so two entries can share a path and the handler can tell them apart. Anyone can send those headers; only `CRON_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](https://vercel.com/docs/cron-jobs/manage-cron-jobs)). A new deployment doesn't interrupt a run in progress.
- **Nothing fires locally.** `vercel dev` and `next dev` have 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](https://vercel.com/docs/cron-jobs#cron-expression-limitations):

- No names: "Cron jobs on Vercel do not support alternative expressions like `MON`, `SUN`, `JAN`, or `DEC`."
- 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](https://vercel.com/docs/cron-jobs/usage-and-pricing) and [function limits](https://vercel.com/docs/functions/limitations#max-duration):

|                                           | 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: for `30 8 * * *`, any point within the hour includes 08:05. On Hobby, write minute `0`.
- **Older limits.** Answers citing 2 cron jobs on Hobby and 40 on Pro describe per-team limits Vercel removed on [January 20, 2026](https://vercel.com/changelog/cron-jobs-now-support-100-per-project-on-every-plan).
- **Duration.** "The duration limits for Cron jobs are identical to those of Vercel Functions." The figures above apply with [Fluid compute](https://vercel.com/docs/fluid-compute), on by default for projects created since April 23, 2025. Above 800 seconds, Pro and Enterprise set `maxDuration` per function, on supported runtimes only ([extended max duration](https://vercel.com/docs/functions/configuring-functions/duration#extended-max-duration-beta)).

[Cloudflare Cron Triggers](https://cronradar.com/blog/cloudflare-cron-triggers) and [cron jobs on Render, Railway, and Fly.io](https://cronradar.com/blog/paas-cron-jobs) 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](https://vercel.com/docs/cron-jobs/manage-cron-jobs#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](https://vercel.com/kb/guide/troubleshooting-vercel-cron-jobs) warns against newlines and special characters in the value. And environment variable changes "only apply to new deployments" ([environment variables](https://vercel.com/docs/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`](https://vercel.com/docs/errors/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](https://vercel.com/docs/functions/configuring-functions/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](https://vercel.com/docs/cron-jobs/manage-cron-jobs#cron-job-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](https://cronradar.com/blog/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](https://vercel.com/kb/guide/troubleshooting-vercel-cron-jobs) 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](https://nextjs.org/docs/app/api-reference/file-conventions/route#version-history)), and with Cache Components, reading `request.headers`, as the secret check does, stops prerendering ([Route Handlers](https://nextjs.org/docs/app/getting-started/route-handlers#with-cache-components)). 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](https://vercel.com/docs/logs/runtime#limits)), 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`](https://vercel.com/docs/cli/crons) (beta), which reads the cron definitions from your production deployment, or run it from the [deployment summary](https://vercel.com/changelog/run-cron-jobs-from-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](https://cronradar.com/blog/node-js-cron-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:

```typescript
// 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()`](https://nextjs.org/docs/app/api-reference/functions/after) (stable since Next.js 15.1) or [`waitUntil()`](https://vercel.com/docs/functions/functions-api-reference/vercel-functions-package#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](https://cronradar.com/blog/cron-job-timeout) 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](https://docs.cronradar.com/schedules)), 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](https://cronradar.com/blog/cron-grace-period) 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](https://cronradar.com/blog/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

1. Create a CronRadar application for production and add its key as `CRONRADAR_API_KEY` in the Vercel project's Production environment only, since cron jobs never run elsewhere. Give any environment you test in its own application and key.
2. Redeploy production so the variable reaches it.
3. Run the job locally with `curl -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/nightly-report`, without the production key.
4. 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.
