Vercel Cron Jobs — A Complete Guide
Vercel Cron Jobs schedule API route invocations from Vercel's edge — no separate scheduler service, no infrastructure. The setup is two files (`vercel.json` + an API route) and a few production patterns: authenticating the cron request, staying within the function duration limit, and dealing with Vercel's UTC-only schedule and per-plan frequency limits.
Updated
Setup
Two pieces:
1. vercel.json at the project root:
{
"crons": [
{
"path": "/api/cron/refresh-cache",
"schedule": "*/5 * * * *"
},
{
"path": "/api/cron/daily-digest",
"schedule": "0 9 * * *"
}
]
}
2. An API route at the matching path. In Next.js App Router:
// app/api/cron/refresh-cache/route.ts
import { NextResponse } from "next/server";
export async function GET() {
// ... your work
return NextResponse.json({ ok: true });
}
In Next.js Pages Router:
// pages/api/cron/refresh-cache.ts
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// ... your work
res.status(200).json({ ok: true });
}
On deploy, Vercel reads vercel.json, registers the crons, and starts invoking the routes at the scheduled times. The dashboard shows registered crons under Settings → Cron Jobs.
The API route handler
Vercel invokes the cron URL with a GET request. Your handler should:
- Verify the request actually came from Vercel (see Authentication)
- Do the work
- Return a 200 status with a meaningful response body (helpful for the cron job logs)
// app/api/cron/refresh-cache/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
// Auth check — guard against an unset secret so a "Bearer undefined" can't slip through
const authHeader = req.headers.get("authorization");
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
const start = Date.now();
try {
const result = await refreshCache();
return NextResponse.json({
ok: true,
durationMs: Date.now() - start,
...result,
});
} catch (error) {
console.error("Cache refresh failed", error);
return NextResponse.json(
{ ok: false, error: String(error) },
{ status: 500 }
);
}
}
Things to know:
- The handler runs in the same environment as any other Vercel function — Node.js or Edge depending on your runtime setting, with all the same constraints (cold starts, regions, secret access).
- Failures are visible in the Vercel dashboard under Logs → filter by your cron path.
- Set
runtime: "edge"on the route if it’s a quick fetch-and-store; otherwise stay on Node for full database driver support. - Use
ctx.waitUntil(promise)in Edge functions for fire-and-forget work that extends beyond your response.
CRON_SECRET authentication
To secure cron invocations, you create an environment variable named CRON_SECRET yourself (Vercel recommends a random string of at least 16 characters) under Settings → Environment Variables. Vercel does not auto-generate it. Once it’s set, Vercel automatically sends its value as an Authorization: Bearer <CRON_SECRET> header on every cron request, and your handler checks for it.
Without auth, your cron URL is public — anyone hitting https://yourapp.vercel.app/api/cron/refresh-cache could trigger it. The standard pattern:
export async function GET(req: NextRequest) {
if (!process.env.CRON_SECRET || req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
// ... work
}
For Edge runtime, the check works the same.
Local development: the CRON_SECRET isn’t set by default in local. Add it to .env.local:
CRON_SECRET=local-dev-secret
Then test the route with the matching header:
curl http://localhost:3000/api/cron/refresh-cache \
-H "Authorization: Bearer local-dev-secret"
For an extra signal, Vercel’s cron requests also carry a user-agent of vercel-cron/1.0 and an x-vercel-cron-schedule header identifying the schedule that fired — you can check those in addition to the bearer token. (The bearer token remains the real security boundary; the headers are just corroboration.)
Plan limits and timeouts
| Limit | Hobby | Pro | Enterprise |
|---|---|---|---|
| Min interval | 24 hours (daily only) | 1 minute | 1 minute |
| Scheduling precision | within the hour (±59 min) | to the minute | to the minute |
| Cron jobs per project | 100 | 100 | 100 |
| Function duration (default / max) | 300s / 300s | 300s / 800s | 300s / 800s |
| Function memory (default / max) | 2 GB / 2 GB | 2 GB / 4 GB | 2 GB / 4 GB |
The Hobby plan effectively only supports 0 0 * * *-style daily schedules — and it triggers within the hour, not to the minute, so 0 1 * * * fires sometime between 1:00 and 1:59. Anything more frequent, or minute-precise, requires Pro or higher. (Pro/Enterprise also offer an 1800s / 30-min extended max duration in beta.)
If your cron work exceeds the function timeout, decouple via a queue:
// app/api/cron/enqueue-work/route.ts
export async function GET(req: NextRequest) {
if (!process.env.CRON_SECRET || req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("Unauthorized", { status: 401 });
}
// Just enqueue, don't do the work here
await fetch("https://qstash.upstash.io/v2/publish/...", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.QSTASH_TOKEN}` },
body: JSON.stringify({ kind: "nightly-report" }),
});
return NextResponse.json({ ok: true });
}
The cron returns in <1s; the actual work runs in a separate worker (Upstash QStash, Inngest, Trigger.dev, AWS SQS + Lambda, etc.).
Timezone (UTC only)
Vercel cron schedules are always interpreted in UTC. There’s no timezone field on the cron config.
To run at 9 AM in a specific zone, you have to translate:
{
"crons": [
{ "path": "/api/cron/morning", "schedule": "0 17 * * *" } // 9 AM PST
]
}
Across DST transitions, this drifts by an hour. Three options:
- Accept the drift — most internal jobs don’t care about 9:00 vs 10:00 across DST
- Two cron entries, date-gated — schedule both PST and PDT offsets, then check inside the handler which one should run based on the current date
- Use a timezone-aware external scheduler that calls Vercel — AWS EventBridge Scheduler has per-schedule timezones; have it POST to your Vercel route
For most apps option 1 is fine. For customer-facing schedules where the wall-clock time matters, option 3 is the cleanest.
Common patterns
Cache refresh / data warm-up:
{ "path": "/api/cron/warm-cache", "schedule": "*/15 * * * *" }
Re-fetch popular data, write to KV/Redis/Edge Config. Bound by the function timeout — for heavy fetches, queue them.
Daily report generation:
{ "path": "/api/cron/daily-report", "schedule": "0 7 * * *" }
Generate yesterday’s report at 7 AM UTC, store in S3/R2/Blob, optionally email.
Pruning stale data:
{ "path": "/api/cron/prune-soft-deleted", "schedule": "0 3 * * *" }
Off-hours cleanup of soft-deleted rows older than N days.
External API polling:
{ "path": "/api/cron/check-webhooks", "schedule": "*/5 * * * *" }
Poll an external service every 5 minutes for changes you couldn’t subscribe to via webhook.
Triggering longer workflows:
{ "path": "/api/cron/dispatch-monthly", "schedule": "0 0 1 * *" }
On the 1st of the month, enqueue a monthly billing job into Inngest or QStash that runs for hours.
For everything else, the common cron patterns page lists 20 schedules with explanations and code snippets.
Frequently asked questions
How do I add a cron job to a Vercel project?
What's the minimum interval for Vercel cron jobs?
Are Vercel cron jobs always UTC?
How do I make sure only Vercel can trigger my cron handler?
What's the timeout for a Vercel cron invocation?
Related
Every 5 Minutes
`*/5 * * * *` — common Vercel cron interval (Pro plan or higher).
PatternDaily at Midnight
`0 0 * * *` — the default Vercel cron for nightly work.
ToolCron Parser
Verify cron expressions before adding them to vercel.json.
GuideCloudflare Workers Cron Triggers
The edge-cron alternative — different limits, similar mental model.
GuideGitHub Actions Scheduled Workflows
Pair with Vercel cron for richer CI-driven scheduling.
GuideNode.js Cron Jobs
For longer-running Node scheduling needs, in-process options.