Vercel Cron Jobs: Scheduled Tasks for Your SaaS in 2026

A step-by-step Vercel cron jobs tutorial: schedule digests, cleanup, and billing checks, secure the endpoint, make runs idempotent, and know when to graduate.

· Justin Boggs

A white wall covered with many analog clocks showing different times

Photo by Donald Wu on Unsplash

A Vercel cron job is a scheduled HTTP request that Vercel makes to one of your API routes on a fixed timetable — every night at 2 a.m., every five minutes, once a week — so recurring work happens without anyone clicking a button. You define the schedule in a vercel.json file, write the route that does the work, and Vercel calls it. For a solo SaaS founder, this is how you send a weekly digest email, clean up expired records, reconcile Stripe subscriptions, or check for failed payments on a schedule. This tutorial walks through setting one up end to end, securing it so strangers cannot trigger it, making it safe to run twice, and knowing the exact point where you should graduate to a real queue.

TL;DR

  • A Vercel cron job is a scheduled HTTP GET to one of your routes, configured in vercel.json with a cron expression and a path.
  • Setup is three pieces: a crons entry in vercel.json, a route handler that does the work, and a CRON_SECRET check so only Vercel can trigger it.
  • Hobby plans run cron jobs at most once per day with per-hour timing; Pro and Enterprise run per-minute with per-minute precision.
  • Vercel does not retry failed runs and can occasionally fire twice, so make every job idempotent — safe to run again — and reconciliation-based.
  • Graduate to a queue or a durable workflow tool when jobs outgrow the function timeout, need retries, or must fan out across many items.

What Vercel cron jobs are and when to use them

A cron job is a task that runs on a schedule instead of in response to a user. The name comes from the classic Unix cron daemon, and the scheduling syntax — the "cron expression" — is the same five-field format engineers have used for decades. On Vercel, the twist is that there is no always-on server running a scheduler. Instead, Vercel makes an HTTP GET request to your project's production deployment at a path you specify, and that request runs a normal Vercel Function. The schedule lives in configuration; the work lives in an ordinary API route.

For an indie SaaS, the use cases are the unglamorous plumbing that keeps a product healthy. Vercel's own documentation lists the common ones directly: automating backups, sending email and Slack notifications, and updating Stripe subscription quantities. In practice you will reach for cron jobs to send a weekly summary email, expire trials that have ended, delete orphaned uploads, retry failed payments, refresh a cached leaderboard, or warm a report before business hours. Anything that should happen "every so often" regardless of whether a user is online is a cron job.

The key mental model is that a cron job is just a route that Vercel calls on a timer. There is no separate worker to deploy, no scheduler process to keep alive, no infrastructure to provision. That simplicity is the whole appeal for a solo founder — and it is also the source of the limits you need to understand, because a cron job inherits every constraint of the Vercel Function it invokes. If your scheduled work is heavy or long-running, that matters, and we will get to exactly where the line is. If you have already met background jobs through a tool like Inngest, cron is the lighter-weight sibling: no durability, no retries, no steps — just "call this endpoint on this schedule."

Setting up your first cron job, step by step

Here is the complete flow. We will build a cron job that sends a daily digest email at 8 a.m. UTC.

First, add a crons array to vercel.json at the root of your project. Each entry has a path — the route Vercel will call — and a schedule in cron expression format.

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    {
      "path": "/api/cron/daily-digest",
      "schedule": "0 8 * * *"
    }
  ]
}

The expression 0 8 * * * means "at minute 0 of hour 8, every day." The five fields are minute, hour, day-of-month, month, and day-of-week. If cron syntax is unfamiliar, crontab.guru translates any expression into plain English and is the tool I keep open whenever I write one. A few Vercel-specific rules matter: the timezone is always UTC, you cannot use named expressions like MON or JAN, and you cannot set both day-of-month and day-of-week at once — when one has a value, the other must be *.

Second, write the route handler at that exact path. In the Next.js App Router, that is app/api/cron/daily-digest/route.ts:

import type { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  // 1. verify the request is really from Vercel (see next section)
  // 2. do the scheduled work
  await sendDailyDigest()
  return Response.json({ success: true })
}

Third, deploy. Cron jobs are registered from your production deployment, so they do not run on preview deployments or local dev — you push to production and Vercel picks up the schedule. You can then see every configured job under Settings → Cron Jobs in the dashboard, and each one has a View Logs button that filters your runtime logs to that job's path. That is the entire setup: a config entry, a route, and a deploy. Everything else in this post is about making that route safe and reliable.

flowchart LR
  S[Vercel Scheduler] -->|HTTP GET at schedule| R["/api/cron/daily-digest"]
  R --> A{CRON_SECRET<br/>valid?}
  A -->|no| X[401 Unauthorized]
  A -->|yes| W[Do idempotent work]
  W --> OK[200 success]

Securing the endpoint so only Vercel can call it

Your cron route is a public URL. Without protection, anyone who guesses /api/cron/daily-digest can hit it and trigger your job — sending duplicate emails, running billing checks, or hammering your database. The fix is a shared secret, and Vercel builds this in.

Add an environment variable named CRON_SECRET to your project — Vercel recommends a random string of at least 16 characters, the kind a password generator produces. When Vercel invokes your cron job, it automatically sends that value as an Authorization: Bearer header. Your route compares the header against the environment variable and rejects anything that does not match:

import type { NextRequest } from 'next/server'

export async function GET(request: NextRequest) {
  const authHeader = request.headers.get('authorization')
  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 })
  }

  await sendDailyDigest()
  return Response.json({ success: true })
}

This is the same discipline every sensitive endpoint needs: verify the caller before doing any work, exactly like you verify a Stripe webhook signature before trusting its payload. The secret never appears in your code or your repo — it lives only in your environment variables, which is where every secret belongs. Set it once in the Vercel dashboard, reference it in the route, and every cron invocation is authenticated with no extra work on your side.

One useful detail for larger setups: every cron request also includes an x-vercel-cron-schedule header containing the exact cron expression that triggered it. If you point several schedules at the same path — a */5 * * * * incremental sync and a 0 0 * * * full sync, for example — you can read that header to decide which variant of the work to run, so one route cleanly handles both cadences.

Understanding the limits before they surprise you

A cron job invokes a Vercel Function, so it inherits the function's limits — and the scheduling itself differs sharply by plan. This is the table I wish every founder read before wiring up their first job, because "why did my hourly cron fail to deploy?" is almost always a Hobby-plan surprise.

| Plan | Cron jobs per project | Minimum interval | Scheduling precision | | --- | --- | --- | --- | | Hobby | 100 | Once per day | Per-hour (±59 min) | | Pro | 100 | Once per minute | Per-minute | | Enterprise | 100 | Once per minute | Per-minute |

Two things about the Hobby plan trip people up. First, Hobby accounts are limited to cron jobs that run once per day — an expression like 0 * * * * (hourly) or */30 * * * * (every 30 minutes) will fail at deploy time with an explicit error, not silently. Second, even a once-daily Hobby job is not precise: a job set for 0 8 * * * can fire anywhere between 08:00 and 08:59, because Vercel spreads Hobby cron load across the hour. If you need a job to run more than once a day, or to run at a precise minute, that is the concrete reason to be on Pro.

The other limit is duration. A cron job stops when its function stops, and the duration limits are identical to those of any Vercel Function — with functions defaulting to a 300-second maximum in 2026. That is plenty for sending a batch of emails or running a cleanup query, but not for processing thousands of records one at a time. Vercel's own guidance when you need more time is to split the work into smaller units or combine the cron trigger with regular HTTP requests to your API. When even that is not enough, it is the signal to graduate — which we cover below. Understanding these limits up front is part of planning the real cost of your infrastructure, because cron executions bill as function invocations like everything else.

Making jobs idempotent (the part people skip)

Here is the rule that separates a reliable cron job from a data-corruption incident: Vercel does not retry failed runs, and it can occasionally fire the same run twice. Cron delivery is best-effort. The docs are direct about both sides of this — a transient network error can mean your function never executes and no log is written, and delivery can also occasionally invoke the same scheduled run more than once. Your job has to be resilient to both a missed run and a duplicate run.

The property that makes a job safe under those conditions is idempotency: running it twice has the same effect as running it once. Vercel's documentation gives the cleanest example of the distinction — "set user status to active" is idempotent, because running it twice leaves the status active either way, while "increment user credit by 10" is not, because running it twice hands out 20 credits. Design every scheduled operation toward the first shape and away from the second.

The practical techniques are straightforward. Check state before you change it: "if this invoice is not already sent, send it," rather than "send this invoice." Use unique IDs to track which items you have already processed, so a second run skips them. And make jobs reconciliation-based — instead of "do today's work," write "process everything outstanding since the last successful run," so a missed run automatically catches up on the next one. This is the same mindset that makes a dunning and failed-payment recovery job trustworthy: it should look at the current state of the world and reconcile it, not assume it ran exactly once at exactly the right moment.

There is a second reliability trap: concurrency. If a job runs longer than the gap between invocations, Vercel can start a second copy while the first is still going, causing race conditions and double-processing. The defenses are a distributed lock (a Redis lock that lets only one instance run at a time), reducing the job's runtime so it finishes before the next tick, or simply running it less often. Vercel recommends using both a lock to prevent overlapping runs and idempotent reconciliation to handle duplicates and misses — belt and suspenders for the jobs that matter.

When to graduate beyond cron

Vercel cron is the right tool for simple recurring work, and the wrong tool for a growing set of jobs. Knowing when you have outgrown it saves you a painful production incident. Here is how cron compares to the tools you graduate to.

| | Vercel Cron | Durable workflow (Inngest) | Message queue (QStash) | | --- | --- | --- | --- | | Trigger | Schedule only | Events, schedules, delays | Messages, scheduled HTTP | | Automatic retries | No | Yes, per step | Yes, on delivery | | Multi-step checkpointing | No | Yes | No | | Runs longer than function timeout | No | Yes, via steps | Per-message, yes | | Fan-out across many items | Awkward | Native | Native | | Best for | Simple periodic jobs | Reliable multi-step workflows | Lightweight queues and delays |

Reach past cron when any of these become true. Your job needs retries — cron gives you none, so anything that must eventually succeed wants a durable tool. Your job has multiple steps that should not restart from zero on failure — that is exactly what Inngest's durable functions provide. Your job processes more items than fit in one function timeout — you want to fan the work out across a queue so each item is its own short invocation. Or your job must run reliably at high frequency with guaranteed delivery, which best-effort cron does not promise.

For most indie SaaS founders, the honest answer is that cron is enough for a long time. A daily digest, a nightly cleanup, a subscription reconciliation that reads current state and fixes it — these run fine as scheduled routes for years. Add the heavier machinery when a specific job's requirements demand it, not before, and keep the simple jobs simple. Wiring the whole thing into a production deploy is part of the broader Next.js SaaS deployment checklist — cron config, secrets, and monitoring all belong in the same pre-launch pass.

Frequently asked questions

How do I test a Vercel cron job locally?

Cron jobs are ordinary API routes, so you test them by making a request to the endpoint directly — for a job at /api/cron/daily-digest, visit http://localhost:3000/api/cron/daily-digest in your browser or hit it with curl. There is no support for triggering the schedule itself in next dev or vercel dev; you invoke the route manually in development and let the real schedule run only in production.

Why did my hourly cron job fail to deploy?

You are almost certainly on the Hobby plan, which limits cron jobs to once per day. Any expression that would fire more than once a day — hourly, every 30 minutes — fails at deploy time with an explicit error. To run jobs more frequently or at a precise minute, upgrade to Pro, which allows per-minute schedules with per-minute precision.

Does Vercel retry a cron job if it fails?

No. Vercel does not retry failed cron invocations, and it may occasionally fire the same run twice. Because delivery is best-effort, your job must be idempotent — safe to run again — and ideally reconciliation-based, processing all outstanding work since the last successful run so a missed invocation catches up on the next one.

How do I stop random people from triggering my cron endpoint?

Set a CRON_SECRET environment variable of at least 16 random characters. Vercel automatically sends it as an Authorization: Bearer header on every cron invocation, and your route checks the header against the variable, returning 401 if it does not match. This ensures only Vercel can trigger the job, the same way a webhook signature ensures only the real provider can call your webhook.

What timezone do Vercel cron schedules use?

Always UTC. There is no timezone configuration, so 0 8 * * * means 8 a.m. UTC, not 8 a.m. in your local time. Convert your intended local time to UTC when writing the expression, and remember it does not shift for daylight saving — a job scheduled for a fixed UTC hour will land at different local clock times across the year.

What happens if my cron job runs longer than the function timeout?

It is terminated when the function hits its maximum duration, defaulting to 300 seconds in 2026. Work still in progress is cut off. If a job legitimately needs more time, split it into smaller units, combine cron with regular API requests to process work in batches, or graduate to a durable workflow tool that runs work across many short steps rather than one long function.

The bottom line

Vercel cron jobs are the simplest way to run scheduled work in a SaaS: a config entry, a route, a deploy, and Vercel calls your endpoint on a timer. The three things that turn a fragile cron job into a reliable one are securing it with a CRON_SECRET, understanding that your plan dictates how often and how precisely it runs, and designing every job to be idempotent so a duplicate or missed run never corrupts your data. Get those right and you can run digests, cleanup, and billing checks for years without touching heavier infrastructure.

If you are building a SaaS with AI coding tools and want scheduled jobs, secret verification, and billing reconciliation already wired into a working Next.js app, Coding Capybaras is the free boilerplate I built for exactly this workflow — and the marketplace has copy-paste prompts to add each integration mentioned above.