# 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.

Maciej Wiatr, 2026-11-09. https://maciejwiatr.com/blog/vercel-schedules-vs-cron-jobs-seo

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](https://vercel.com/docs/schedules)). This table combines Vercel's own comparison with the [limits page](https://vercel.com/docs/schedules/limits), the [concepts page](https://vercel.com/docs/schedules/concepts) and the [legacy Cron pricing page](https://vercel.com/docs/schedules/cron-jobs/usage-and-pricing), as of November 2026.

| | Cron Jobs (legacy) | Schedules (beta) |
|---|---|---|
| Targets | HTTP GET to a public path | GET or POST to a path, a private function, or a queue topic |
| Timezone | UTC only | Any IANA timezone per schedule, default UTC, DST followed |
| Environments | Production only | Production, Preview branches, custom environments |
| Timing | Pro/Ent: within the scheduled minute | Jitter window of 1 to 15 min (Pro/Ent default 1) |
| Hobby frequency | Once a day, ±59 min | Every 15 min, fixed 15 min jitter |
| Limits | 100 per project, all plans | 1,000 / 10,000 / 100,000 per team (Hobby / Pro / Ent) |
| Hobby caps | None listed beyond once a day | 100 static per deployment, 5,000 runs and 500 creations per period |
| Pricing | No extra charge beyond functions | Pro/Ent: $5 per 1M runs, $10 per 1M creations, plus target usage |
| Retries | None | Queue topic targets only |
| Execution history | Function logs only | Per schedule, dashboard and CLI |
| Pause / run now | One project-wide switch | Per schedule, no redeploy |
| One-time and runtime-created | No | Yes, queue topic target only |

The $5 and $10 figures come from the [changelog](https://vercel.com/changelog/vercel-schedules-is-now-in-public-beta). 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](https://vercel.com/docs/schedules/migrate-from-cron-jobs) 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.](https://maciejwiatr.com/assets/blog/vercel-schedules-vs-cron-jobs-seo/shot-2.webp "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:

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

And after:

```json
{
  "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](https://vercel.com/docs/schedules/configuration)). 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](https://vercel.com/docs/schedules/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](https://vercel.com/docs/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](https://vercel.com/docs/queues/pricing)). Set `maxDeliveries` on anything that calls an external API.

If runs fail with no visible cause, Vercel's [troubleshooting guide](https://vercel.com/guides/troubleshooting-vercel-cron-jobs) 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.](https://maciejwiatr.com/assets/blog/vercel-schedules-vs-cron-jobs-seo/shot-1.webp "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](https://vercel.com/docs/schedules/manage)). This replaces the workaround of pointing QStash or GitHub Actions at preview URLs ([community thread](https://community.vercel.com/t/workaround-for-cron-jobs-on-preview/1844)).

```bash
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](https://vercel.com/docs/schedules/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.

```mermaid
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
```

| Job | Target | Why |
|---|---|---|
| Sitemap regen | Path (or function) | Idempotent. The next run repairs a missed one |
| revalidatePath batches | Queue topic, consumer route | Needs retries and `maxConcurrency` |
| IndexNow submit | Queue topic, consumer route | 429s and 5xx need retries |
| Full rebuild via deploy hook | Path | One 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](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap)). Write `lastmod` from your data's real update time. Don't ping Google afterwards: the endpoint was [deprecated in June 2023](https://developers.google.com/search/blog/2023/06/sitemaps-lastmod-ping) and returns 404. More on index splits is in the [Next.js programmatic SEO guide](https://maciejwiatr.com/blog/programmatic-seo-nextjs-app-router-guide).

**revalidatePath batches.** In a Route Handler, [`revalidatePath`](https://nextjs.org/docs/app/api-reference/functions/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](https://www.indexnow.org/documentation)). 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](https://maciejwiatr.com/programmatic-seo/indexing) 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`.

```ts
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

- You are on Pro and a job must start inside an exact minute. Cron Jobs still do that, and jitter does not.
- Your pipeline already runs on an external queue or orchestrator with retries. Adding a second scheduler adds a second place to debug.
- You are on Hobby with several high-frequency jobs. The 5,000-run cap will skip runs.
- You can't live with beta changes to behavior, APIs or limits on jobs your index depends on.

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.
