Vercel Cron Runs Twice on Deploy (2026 Fix)

Yatish Goel

Yatish Goel

Co-Founder & CTO

February 12, 2026
#vercel#nextjs#cron-jobs#idempotency#serverless#production-incidents#webhooks

If you have a Vercel Cron hitting a Next.js route, you expect it to run on a schedule. Not when you deploy. Not twice in the same minute.

But I keep seeing the same incident pattern in 2026:

- Founder ships a quick scheduled job (email digests, Stripe sync, nightly cleanup)

- First week is fine

- Then a deploy happens, and the job fires

- Or the job fires twice at the scheduled minute

- People get two emails, Stripe gets double writes, data gets weird

This post is the fix. Not theory. The guardrails we add on day one when we build scheduled work for US or UK based startups that cannot afford “oops, ran twice”.

What is actually happening

Two separate problems get mixed together:

1) Your endpoint is being called by something other than Vercel Cron.

2) Vercel Cron is calling you more than once, and your code is not safe to re-run.

The first one is the classic. The second one is the one that hurts because you will not notice until you charge money or send email.

Here is the key line from Vercel’s own docs: cron triggers are HTTP GET requests to your production URL, and the cron user agent is `vercel-cron/1.0`.

So your “cron job” is just a public URL getting hit on the internet. Treat it that way.

The fastest way to confirm it is a real Cron call

Log these three things for every request to your cron route:

- `req.headers['user-agent']`

- `req.headers['x-vercel-cron']` (if present)

- `req.headers['x-forwarded-for']`

Then deploy, wait for the weird call, and check logs.

If the user agent is not `vercel-cron/1.0`, it is not Vercel Cron.

If it is `vercel-cron/1.0` but you see two calls in the same minute, assume duplicates can happen and design for it.

Why cron endpoints get hit during deploy

The uncomfortable truth: lots of teams accidentally make their cron route part of normal app traffic.

The common causes we see:

1) A “health check” pings the same path

I have seen teams use `/api/cron` as a “keep alive” endpoint. Then they add Vercel Cron later. Now the path has two callers.

Fix: split paths. Use `/api/healthz` for health checks. Cron gets its own route, like `/api/jobs/nightly-sync`.

2) You wired it into build or deploy scripts

Someone adds a post-deploy hook like “warm the cache” and hits the cron URL because it was handy.

Fix: do not call job endpoints from build. Ever.

3) Preview deployments calling production secrets

A preview environment has the cron route deployed too. A mis-set env var points it at production DB, and now previews do real work.

Fix: hard block non-production.

In Next.js route handlers:

- If `process.env.VERCEL_ENV` is not `production`, return 204.

- Or check `process.env.NEXT_PUBLIC_VERCEL_ENV` on the client and never show links that could hit cron.

The 2026 rule: cron code must be idempotent

You cannot “trust the scheduler”. You trust your own guard rails.

Idempotent means: if the same job runs twice with the same inputs, the second run does not change anything.

In practice, it means you do three things:

1) Create a job run id for each scheduled run window

2) Store it in your DB with a unique constraint

3) Exit early if it already exists

A simple job run lock (Postgres)

Create a table:

- `job_runs(job_name text, run_key text, created_at timestamptz)`

- Unique index on `(job_name, run_key)`

Then in your cron route:

- Compute `run_key` as the minute bucket, like `2026-02-18T05:20Z`

- Try insert

- If insert fails due to unique constraint, return 200 and stop

This is the single cheapest way to prevent double sends.

Cost: about 30 minutes to add, if you are already on Postgres.

Make it private: require a secret header

Cron routes should not be public.

Vercel Cron is an HTTP request. Anyone can also make an HTTP request.

So require a shared secret. Use a header like:

- `x-cron-secret: <value>`

And store the secret in Vercel env vars.

If the header is missing or wrong, return 404.

Why 404 and not 401?

Because I do not want scanners to learn that the path exists.

Use Vercel signals, but do not rely on them

From Vercel docs, cron user agent is `vercel-cron/1.0`. Use it as a hint.

But do not make it your only auth.

User agents can be spoofed in 5 seconds.

What you can do safely:

- If user agent is not `vercel-cron/1.0`, log a warning

- Still require the secret header

The “ran on deploy” bug: the real pattern we see

We got pulled into a rescue last month. US based SaaS. Next.js 14.

Symptom:

- A nightly job that syncs Stripe quantities

- It ran every deploy

- They thought Vercel Cron was “broken”

Root cause:

- Their `vercel.json` had a cron path `/api/cron/stripe-sync`

- Their deploy pipeline also hit that path to “verify the endpoint”

So every deploy triggered it.

Fix:

- Remove deploy ping

- Add secret header

- Add run lock table

Time:

- 2.5 hours including tests

Impact:

- No more double writes

- Stripe logs calmed down

- Support tickets stopped

If your job charges money, add a second safety net

If the job can charge a card, create invoices, or send transactional email, you want a second layer.

I like this combo:

1) Job run lock in DB

2) Idempotency key on the external call

Examples:

- Stripe: idempotency keys on requests that create objects

- Email provider: de-dupe key if they support it, or store your own “sent” record keyed by (template, user_id, run_key)

If you only do one layer, do the DB lock.

Debug checklist (copy this into your incident doc)

When a cron route fires twice, do this in order:

1) Confirm environment: `VERCEL_ENV` in logs

2) Confirm caller: `user-agent`, request IP

3) Confirm auth: did it include your secret header

4) Confirm schedule: does `vercel.json` define more than one cron for the same path

5) Confirm duplicates: do you have two Vercel projects pointing to the same domain

6) Confirm code: are you doing retries on fetch without idempotency

Point 5 sounds silly, but it happens after “we moved from personal to team account” and left old crons around.

How we structure cron routes in Next.js (2026)

We avoid a single `/api/cron` handler that switches on query params.

We do one route per job:

- `/api/jobs/nightly-cleanup`

- `/api/jobs/stripe-quantity-sync`

- `/api/jobs/weekly-digest`

Inside each route:

1) `if (process.env.VERCEL_ENV !== 'production') return 204`

2) Validate `x-cron-secret`

3) Compute run_key

4) Acquire lock (unique insert)

5) Run the work

6) Write a summary row (counts, duration)

Yes, it is boring. That is the point.

The part nobody tells you: retries are normal

Even if the scheduler behaves, the network does not.

Requests can time out. Functions can cold start. Vercel can retry. Your client code can retry.

So your job code must be safe when re-entered.

If you hate “idempotency” as a word, fine.

Call it “do not break production twice”.

Cost and timeline for a proper fix

For most HeyDev style rescues, the numbers look like this:

- 1 to 2 hours: add secret auth, block non-prod, basic logging

- 2 to 4 hours: add DB run locks + a tiny admin page or log output

- 4 to 8 hours: refactor a messy cron into separate job routes and add tests

If your cron is touching Stripe, I push for the 4 to 8 hour version.

Because the cheap fix is still cheap compared to the day you refund angry customers.

Quick code sketch (route handler)

Pseudocode, but close to real:

- Read secret header

- Compare to env

- Build run key

- Insert into `job_runs`

- If conflict, return

- Run job

If you want this implemented in your stack (Postgres, Prisma, Supabase, Neon, whatever), that is basically a half day project.

When you should not use Vercel Cron at all

Hot take: do not run heavy ETL jobs in a serverless function.

If the job needs 10 minutes, hits 20 APIs, or processes 5 million rows, move it.

Use:

- a worker on Fly.io

- a queue (SQS, Cloud Tasks)

- a real scheduler (Temporal, Cloud Scheduler)

Vercel Cron is great for “poke this endpoint on a schedule”. It is not your data pipeline.

Bottom line

If your cron route can cause harm, assume it will run twice.

Make it private.

Lock it.

And log enough to know who called it.

If you are a founder in the US, UK, or EU and you are seeing double runs right now, fix the lock first. It is the fastest way to stop the bleeding.

One more gotcha: caching and prefetch

If you put your job behind a path that your app links to, Next.js can prefetch it. That is rare, but I have seen it when someone adds a debug link in an admin page.

Make sure job routes are never linked from UI, never used as fetch targets from the client, and always return 404 unless the secret header is present.

Also, set `Cache-Control: no-store` on cron responses. It keeps weird intermediary caching from masking issues when you are debugging.

---

Related reading

Yatish Goel

Yatish Goel

Co-Founder & CTO

US Startup ExperienceIIT Kanpur

Full-stack architect with US startup experience and an IIT Kanpur degree. Yatish drives the technical vision at HeyDev, designing robust architectures and leading development across web, mobile, and AI projects.

Related Articles