cronuru
Guide

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:

  1. Verify the request actually came from Vercel (see Authentication)
  2. Do the work
  3. 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

LimitHobbyProEnterprise
Min interval24 hours (daily only)1 minute1 minute
Scheduling precisionwithin the hour (±59 min)to the minuteto the minute
Cron jobs per project100100100
Function duration (default / max)300s / 300s300s / 800s300s / 800s
Function memory (default / max)2 GB / 2 GB2 GB / 4 GB2 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:

  1. Accept the drift — most internal jobs don’t care about 9:00 vs 10:00 across DST
  2. 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
  3. 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?
Add a `crons` array to `vercel.json` with one or more entries. Each entry has a `path` (the API route URL to invoke) and a `schedule` (a 5-field Unix cron expression). On deploy, Vercel registers the cron and invokes the route at the scheduled times.
What's the minimum interval for Vercel cron jobs?
Hobby plan: once per day per cron. Pro plan: 1 minute. Enterprise: 1 minute. The Hobby plan effectively only supports daily schedules — `0 0 * * *` works, `*/5 * * * *` does not.
Are Vercel cron jobs always UTC?
Yes. There's no per-cron timezone option. To run at 9 AM Pacific, you'd need to offset manually — schedule `0 17 * * *` for 9 AM PST (UTC-8). The hour drifts by one across DST transitions unless you use two cron entries gated by date.
How do I make sure only Vercel can trigger my cron handler?
Vercel automatically sends a request with an `Authorization: Bearer ` header (the CRON_SECRET is set as an environment variable in your project). Check this header at the top of your route handler and return 401 if it doesn't match — anyone could hit the public URL otherwise.
What's the timeout for a Vercel cron invocation?
Same as any Vercel function. Hobby: 10 seconds. Pro: 60 seconds (configurable up to 300s). Enterprise: 900 seconds (15 minutes). For longer work, the standard pattern is to have the cron enqueue work into a queue (Inngest, Trigger.dev, Upstash QStash, AWS SQS) and let a separate worker process it.