Vercel Schedules vs Cron Jobs for programmatic SEO jobs

How Vercel Schedules differ from legacy Cron Jobs, how to migrate vercel.json safely, and which target to use so sitemap, revalidation and IndexNow jobs retry.

Vercel moved Cron Jobs to legacy status on 5 November 2026, when it put Schedules into public beta. For a programmatic SEO site, the vercel schedules vs cron jobs question has two parts. Moving the config over is small. The bigger decision is which target each SEO job runs on: a route, a private function or a queue topic. That choice decides whether a failed sitemap rebuild, revalidation batch or IndexNow submit gets retried or is lost without a trace. Everything below comes from Vercel’s docs, checked in November 2026. Schedules is in beta, and the docs warn that “Behavior, APIs, and limits may change before general availability.”

Vercel Schedules vs Cron Jobs: what is the difference?

Schedules can run on a cron cadence or once at a set time. A schedule can call a path, run a private function or publish to a Vercel Queue topic (Schedules overview). This table combines Vercel’s own comparison with the limits page, the concepts page and the legacy Cron pricing page, as of November 2026.

Cron Jobs (legacy)Schedules (beta)
TargetsHTTP GET to a public pathGET or POST to a path, a private function, or a queue topic
TimezoneUTC onlyAny IANA timezone per schedule, default UTC, DST followed
EnvironmentsProduction onlyProduction, Preview branches, custom environments
TimingPro/Ent: within the scheduled minuteJitter window of 1 to 15 min (Pro/Ent default 1)
Hobby frequencyOnce a day, ±59 minEvery 15 min, fixed 15 min jitter
Limits100 per project, all plans1,000 / 10,000 / 100,000 per team (Hobby / Pro / Ent)
Hobby capsNone listed beyond once a day100 static per deployment, 5,000 runs and 500 creations per period
PricingNo extra charge beyond functionsPro/Ent: $5 per 1M runs, $10 per 1M creations, plus target usage
RetriesNoneQueue topic targets only
Execution historyFunction logs onlyPer schedule, dashboard and CLI
Pause / run nowOne project-wide switchPer schedule, no redeploy
One-time and runtime-createdNoYes, queue topic target only

The $5 and $10 figures come from the changelog. I could not find how many runs Pro includes before charges start, so treat that as undocumented. History retention is also undocumented on the official pages.

Do I have to migrate my vercel.json crons to Schedules?

Not yet. The changelog says “Existing Cron Jobs continue to work.” The migration guide says Cron Jobs “will be migrated to Schedules in the future.” No date is published. Migrating early buys you preview testing, per-schedule history and queue retries. If you are on Pro and need an exact minute, staying on Cron Jobs for now is reasonable.

Vercel changelog excerpt showing a Schedules.create code sample, a note that Cron Jobs keep working, and Schedules pricing.
Vercel's launch post says existing Cron Jobs keep working, so you don't have to migrate yet, and lists pricing from $5 per 1M runs plus Hobby's free monthly allowance (vercel.com, Nov 2026)

How do I migrate a Vercel cron job to Schedules without changing route code?

Here is a nightly sitemap cron before the move:

{
  "crons": [
    { "path": "/api/sitemap-regen", "schedule": "0 3 * * *" }
  ]
}

And after:

{
  "schedules": {
    "sitemap-regen": {
      "path": "/api/sitemap-regen",
      "schedule": "0 3 * * *",
      "requestMethod": "GET"
    }
  }
}

Four details matter:

  1. Add requestMethod: "GET". Path schedules default to POST (configuration reference). A GET-only route handler would stop working without it.
  2. Delete the crons entry in the same deploy. The guide is blunt: “If you keep both, your route runs twice for each occurrence.”
  3. Keep CRON_SECRET. Path targets still receive Authorization: Bearer <CRON_SECRET> (handle-runs).
  4. Leave out timezone to stay on UTC. If you add one, rewrite the cron expression to match.

Pick the name carefully. Renaming a schedule deletes it and creates a new one, which loses its history. Deploy with vercel deploy --prod, then check it with Run Schedule in the dashboard or vercel schedules invoke sitemap-regen.

Later you can move to a private function: wrap the handler in handleSchedule, give it a schedule/v1beta trigger as its only trigger, and swap path for function using the build-output identifier (api/cleanup, not api/cleanup.ts). One caveat for Next.js: the docs don’t show whether an App Router route.ts can carry that trigger. Run vercel build and check before you rely on it. Function targets also get no authorization header, so they don’t need the secret check.

Does Vercel Schedules retry failed runs?

Only queue topic targets do. For paths, “Vercel doesn’t retry a run if your route returns an error or times out.” Functions don’t retry either, and legacy Cron Jobs never did. Vercel’s advice: “If a failed run must be retried, target a queue topic.”

Queue messages live 60 seconds to 7 days, 24 hours by default. retryAfterSeconds defaults to 60, maxDeliveries defaults to unlimited, and there is no built-in dead-letter queue (Queues concepts). Two gotchas. Messages stick to the deployment that created them, so a broken old deployment can keep retrying for up to 24 hours after you roll forward. A delivery that times out is billed for the full maxDuration (Queues pricing). Set maxDeliveries on anything that calls an external API.

If runs fail with no visible cause, Vercel’s troubleshooting guide points to WAF custom rules and Attack Challenge Mode. A community user found that a geo-block rule had stopped their cron.

What are the limits on Hobby vs Pro?

The table covers frequency and counts. Two Hobby rules are easy to miss. When a Hobby team passes 5,000 runs in a billing period, scheduled runs are skipped until the next period. If a Pro team downgrades, any schedule that runs more often than every 15 minutes, or has jitter under 15, is deleted and “doesn’t come back when the team upgrades.”

Vercel Schedules Limits docs page with a table comparing schedules per team, jitter window, cron frequency and payload size across plans.
Compare the Hobby, Pro and Enterprise columns: 1,000 vs 10,000 vs 100,000 schedules per team, and Hobby's fixed 15-minute jitter and 15-minute minimum interval (vercel.com, Nov 2026)

Also note that 5,000 runs is about 52 days of one schedule firing every 15 minutes. Two such schedules on Hobby will use up the cap before the month ends.

Can Schedules run on preview deployments?

Yes. Each Preview branch gets its own schedules, and they start inactive (manage docs). This replaces the workaround of pointing QStash or GitHub Actions at preview URLs (community thread).

vercel schedules invoke sitemap-regen --git-branch feature-x
vercel schedules enable sitemap-regen --git-branch feature-x --for 1h
vercel schedules invoke sitemap-regen --local

The last command runs against vercel dev (local development). My own rule, which is not from Vercel: SEO jobs should do nothing that leaves the deployment unless VERCEL_ENV === 'production'. A preview branch that submits URLs to IndexNow shares them with every participating engine.

Which target fits each programmatic SEO job?

This is my mapping, built on the retry facts above. The diagram shows the split: light jobs on a path, jobs that must not fail silently on a queue.

flowchart TD
    A[Sitemap schedule] --> B[Path target]
    B --> C[Write sitemap files]
    D[Changes schedule] --> E[Revalidate topic]
    D --> F[IndexNow topic]
    E --> G[Chunked revalidatePath]
    F --> H[POST 10k URLs]
    G --> I[Retry on failure]
    H --> I
JobTargetWhy
Sitemap regenPath (or function)Idempotent. The next run repairs a missed one
revalidatePath batchesQueue topic, consumer routeNeeds retries and maxConcurrency
IndexNow submitQueue topic, consumer route429s and 5xx need retries
Full rebuild via deploy hookPathOne call, easy to rerun by hand

Sitemap regen. Google caps each sitemap at 50,000 URLs or 50 MB uncompressed, ignores priority and changefreq, and uses lastmod only if it is “consistently and verifiably accurate” (Google). Write lastmod from your data’s real update time. Don’t ping Google afterwards: the endpoint was deprecated in June 2023 and returns 404. More on index splits is in the Next.js programmatic SEO guide.

revalidatePath batches. In a Route Handler, revalidatePath marks a path, and the page regenerates “on the next visit.” A batch of 10,000 paths is 10,000 invalidations. It does not mean 10,000 renders or 10,000 database reads right away. Passing /product/[slug] with 'page' won’t regenerate every product at once. Paths are capped at 1,024 characters, and with rewrites you pass the destination path. Use a consumer route, since revalidatePath only runs inside Next.js server code.

IndexNow. Up to 10,000 URLs per POST, and only added, updated or deleted URLs (IndexNow docs). A 202 means key validation is pending, 403 is a bad key, and 429 means too many requests. Google tested the protocol in 2021, and I found no current statement that it uses it, so measure Google crawling through your indexing reports instead.

Why does my schedule run twice, and how do I make SEO jobs idempotent?

Vercel lists the causes: duplicate deliveries it “can’t rule out,” catch-up runs after delays (an every-minute schedule delayed two hours fires up to 12 times), slow runs overlapping because “a slow run doesn’t delay the next one,” and leftover crons entries. The fix is the execution ID, which “stays the same for every delivery of that occurrence.” Path routes read it from ce-id, functions from metadata.executionId, and queue consumers from message.executionId.

export async function GET(req: Request) {
  if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }
  if (process.env.VERCEL_ENV !== 'production') return new Response('skipped');

  const runId = req.headers.get('ce-id');
  if (!runId || (await alreadyProcessed(runId))) return new Response('duplicate');

  const since = await lastSuccessfulRun();      // catch up, don't assume one interval
  await regenerateSitemaps({ changedSince: since }); // overwrite files, never append
  await markProcessed(runId);
  return new Response('ok');
}

alreadyProcessed, lastSuccessfulRun and markProcessed stand for whatever store you already have. Add a lock if a run can take longer than the interval, and use ce-vssscheduledat to return early on stale catch-up runs.

When not to use Schedules

For a site with hundreds of thousands of templated pages, the change that matters is retries. Sitemap jobs can stay on a path. Anything that pushes changed URLs to caches or search engines should go through a queue with an idempotent consumer.