Vercel Cron as the schedule default
Vercel Cron, the platform-native scheduler that fires a secured HTTP GET at your route handler so recurring work runs on a clock, with no queue, worker, or second platform.
Every web app quietly accumulates a drawer of jobs nobody clicks: emailing the nightly digest, rolling last week’s usage into a summary row, sweeping the trials that expired overnight, reconciling your billing state against Stripe. No button, no request, no user waiting. They run on a clock.
The tiers from Inline, then after() both need a request to hang off, because something has to call your code. A digest that goes out at 9am has no caller. So where does work that runs on a schedule live, and when does it stop fitting the simplest answer?
In one sentence: Vercel Cron is a scheduled HTTP GET to a route handler you already know how to write.
No queue, no worker, no second platform, because the platform you deploy to already ships a scheduler.
By the end of this lesson you’ll have a secured, idempotent trial-expiry sweep you can test on your laptop with curl, and you’ll be able to name the moment a scheduled job outgrows Vercel Cron.
Every schedule here is static and anchored to UTC, fixed at deploy time; per-customer timezones and jobs too big for one invocation come later in this chapter.
How a Vercel cron runs
Section titled “How a Vercel cron runs”A Vercel cron is an external scheduler : at the cadence you declare, it makes a plain HTTP GET to a path in your project’s production deployment. The handler at that path runs as an ordinary serverless function invocation , with the same runtime, time limit, and logs as any other route. It does its work and returns.
GET to a public
path you own. The handler runs as a normal invocation — and the secret rides
along on every request.
Three consequences fall out of this, and each shapes the rest of the lesson. Because a cron is just an HTTP GET to a path:
- The path is a public URL. Anyone who can guess
/api/cron/sweep-trialscan send a GET to it. That trust boundary is the next section. - The handler is bounded by the function time limit. The same wall-clock cap applies as any invocation: the 5-minute Hobby / 13-minute Pro wall from the last lesson. A job that runs long gets killed mid-execution.
- The request can arrive more than once. Network delivery isn’t perfect, so a scheduled GET can arrive twice, or not at all. That forces idempotency, two sections down.
Two smaller facts ride along, worth knowing so nothing surprises you in the logs.
Every cron request carries the user agent vercel-cron/1.0 and an x-vercel-cron-schedule header holding the exact expression that fired it, which tells schedules apart when several point at one path.
And crons fire against your production deployment only, never a preview deploy and never your local next dev, which is why the local-testing section exists: you call the route yourself rather than wait for the scheduler.
The config and the handler file
Section titled “The config and the handler file”A cron is two artifacts that point at each other: a config entry that declares when, and a route handler that declares what.
{ "$schema": "https://openapi.vercel.sh/vercel.json", "crons": [{ "path": "/api/cron/sweep-trials", "schedule": "0 * * * *" }]}The schedule lives at the project root, in vercel.json. Each entry in crons maps one cron expression to one path, and Vercel hits that path with a GET at the declared cadence. 0 * * * * means every hour, on the hour, a sub-daily schedule that needs the Pro tier. The optional $schema line gives you editor autocomplete and validation.
export const GET = () => { // verify CRON_SECRET, then do the work return Response.json({ ok: true });};The handler lives at app/api/cron/sweep-trials/route.ts, since the path in vercel.json is the route folder. It’s an ordinary route handler with a named GET export, a shell for the verification and work that follow. Note the named GET, not the default export pages use: route handlers export each HTTP method by name.
One folder per cron job, one route.ts per folder: app/api/cron/<name>/route.ts.
Each job gets its own path, so you can filter the logs by requestPath:/api/cron/<name> to see exactly one job’s runs.
A project with two crons lays out like this.
Directoryapp/
Directoryapi/
Directorycron/
Directorysweep-trials/
- route.ts the trial-expiry sweep we build in this lesson
Directorydaily-digest/
- route.ts a sibling job — one folder each
Securing the public cron endpoint
Section titled “Securing the public cron endpoint”The scheduler sends a GET to /api/cron/sweep-trials, but nothing on the wire stops you from sending the same GET.
The path is public, so anyone who finds it can trigger your trial sweep at will.
Before the handler does anything useful, it has to answer one question: did this request come from Vercel’s scheduler, or from a stranger who guessed the URL?
This is the third trust boundary the course has built.
The first two were webhooks: the Stripe webhook, where an unauthenticated stranger hands you a body claiming a customer paid, and the Resend webhook.
Both proved identity with an HMAC signature over the request bytes.
A cron has no body to sign, so Vercel uses a shared secret instead: it attaches Authorization: Bearer ${CRON_SECRET} to every cron request, and your handler checks that the token matches the secret only you and Vercel know.
A bearer token makes the guard a string comparison, but a careful one:
import { timingSafeEqual } from 'node:crypto';
import { env } from '@/env';
export const runtime = 'nodejs';
export const GET = (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
// verified — the work runs past this line return Response.json({ ok: true });};
const isFromVercelCron = (request: Request): boolean => { const header = request.headers.get('authorization') ?? ''; const expected = `Bearer ${env.CRON_SECRET}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);};The guard runs first. If isFromVercelCron fails, the function returns before any query, log, or work, the same verify-first posture as the Stripe webhook.
import { timingSafeEqual } from 'node:crypto';
import { env } from '@/env';
export const runtime = 'nodejs';
export const GET = (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
// verified — the work runs past this line return Response.json({ ok: true });};
const isFromVercelCron = (request: Request): boolean => { const header = request.headers.get('authorization') ?? ''; const expected = `Bearer ${env.CRON_SECRET}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);};On a mismatch or missing header, return 401 and stop, not the 400 the Stripe webhook returned. The prose below explains the difference.
import { timingSafeEqual } from 'node:crypto';
import { env } from '@/env';
export const runtime = 'nodejs';
export const GET = (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
// verified — the work runs past this line return Response.json({ ok: true });};
const isFromVercelCron = (request: Request): boolean => { const header = request.headers.get('authorization') ?? ''; const expected = `Bearer ${env.CRON_SECRET}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);};Build the two strings. header defaults to an empty string when absent, so a missing header fails the length check. The secret comes from validated env, never a bare process.env lookup.
import { timingSafeEqual } from 'node:crypto';
import { env } from '@/env';
export const runtime = 'nodejs';
export const GET = (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
// verified — the work runs past this line return Response.json({ ok: true });};
const isFromVercelCron = (request: Request): boolean => { const header = request.headers.get('authorization') ?? ''; const expected = `Bearer ${env.CRON_SECRET}`; const a = Buffer.from(header); const b = Buffer.from(expected); return a.length === b.length && timingSafeEqual(a, b);};Compare in constant time: timingSafeEqual always runs the full buffer instead of bailing on the first mismatched byte. The length guard comes first because timingSafeEqual throws on length-mismatched inputs.
Two decisions in that guard deserve to be said out loud.
Return 401, not 400. The two boundaries differ. A webhook signature failure is a malformed proof: a legitimate but misconfigured sender, and a 400 tells it to stop retrying. A cron auth failure is missing identity on a private endpoint: the only callers are Vercel with the right secret or an attacker, so 401, “I don’t know who you are,” is the honest status, and the one Vercel’s docs use.
Compare in constant time.
Vercel’s docs show a plain authHeader !== `Bearer ${secret}` .
A timing attack on a bearer token is low-severity and hard to land across a network, but the reflex from the Stripe boundary is to compare secrets in constant time every time.
It costs one helper and closes the attack class for good, so we diverge from the docs: timingSafeEqual, length-checked first, on the Node runtime.
The secret goes where every secret in this course goes, into validated env, so a missing value fails the build instead of surfacing as a mysterious 401 in production:
server: { // ...existing server vars CRON_SECRET: z.string().min(16),},CRON_SECRET lives in your Vercel project’s environment variables and your local .env, validated by the same @t3-oss/env-nextjs setup you’ve used since the Stripe keys; Vercel recommends a random string of at least 16 characters.
It’s server-only, never bundled to the client and never logged.
Guard it like a session secret, because it is one: the single thing between the public internet and your scheduled jobs.
Best-effort delivery: missed and duplicate runs
Section titled “Best-effort delivery: missed and duplicate runs”You’ve wired the schedule and secured the door. Now for the fact that shapes everything the handler does behind that door, and that most people get subtly wrong about cron.
Vercel cron delivery is best-effort. Not “exactly once,” and not even the “at-least-once” from the webhook lesson. The scheduler tries to deliver each run, and that attempt can fail in both directions:
- A run can be missed. A transient network error stops the request from reaching your function. The run never happens, and no log is produced, so you won’t even see a failure. Your handler can’t assume “I definitely ran an hour ago.”
- A run can be duplicated. Delivery occasionally fires the same scheduled run more than once, seconds apart. Your handler can’t assume “this is the only time I’m running for this tick.”
Ignore either and the handler breaks. Assume it always runs, and a missed tick silently drops an hour of work. Assume it runs exactly once, and a duplicate tick does the work twice: double emails, double charges, double rows.
One pattern handles both: make the handler idempotent and reconciliation-based. Don’t compute a delta from “the last time I ran,” because you don’t know when that was, or whether it happened. Instead, every run queries and processes all the outstanding work since the last successful completion, in a way that’s safe to repeat. Vercel’s own framing draws the line: “set this account’s status to active” is safe to repeat, since two runs leave the status active; “increment this account’s credit by 10” is not, since two runs hand out 20.
Reconciliation handles both failures for free. A missed run is fine: the next run reconciles everything still outstanding, including whatever the missed run would have done. A duplicate run is fine too: the second copy finds nothing left to do, because the first already did it. You write no special cases; the reconciliation query makes both non-events.
This connects to Claim once, mutate once, and the connection tells you exactly when you need extra machinery. A cron job’s effect is one of two kinds:
- A SQL UPDATE on a predicate, like the trial sweep you’re about to build. The predicate is the idempotency: a re-run re-evaluates the same
WHEREclause, which now matches nothing because the first run already changed those rows. No dedup key needed; the database is the source of truth and it converges. - An external side effect, like sending an email or charging a card. Repetition here is visible to a human: a second email lands in the inbox, a second charge hits the statement, and the database can’t un-send either. These need the dedup-and-transact shape from the webhook chapter: a claim row under a unique key (typically
cron:<name>:<yyyy-mm-dd>), written in the same transaction as the work, so the second run finds the claim taken and does nothing.
The one-line discriminator: idempotent-by-predicate jobs need no key; user-visible-side-effect jobs need the claim. For each job below, decide whether it survives a duplicate run as written, or needs a dedup key. Watch the line between changing a database row and doing something a human can see.
A best-effort scheduler can fire the same run twice. Sort each job by whether it survives a duplicate run as-is, or needs a dedup key to stay safe. Drag each item into the bucket it belongs to, then press Check.
UPDATE trials SET status='past_due' WHERE status='trialing' AND current_period_end < now()overdueINSERT one usage-rollup row for last weekThe “safe” column either filters on a predicate the first run invalidates, or recomputes a value from current state so a second run lands on the same answer. The “needs a key” column escapes the database, into an inbox, onto a statement, or as a new row, where it can’t be silently re-converged. The trial sweep you’ll build next is squarely safe, which is what makes it such a clean first cron.
Building the trial-expiry sweep
Section titled “Building the trial-expiry sweep”The job runs once an hour, finds every trial whose window has closed, moves it out of the trialing state, and records each change in the audit log. It anchors the chapter’s off-invocation work: it fits one invocation, tolerates a missed or duplicated run, and needs no retry.
Recall the plan_entitlements row from Plan entitlements.
Each organization has one entitlement row whose status mirrors the Stripe subscription lifecycle.
A trial is a row with status = 'trialing', and its window ends at currentPeriodEnd.
Once that timestamp passes without converting to a paid active subscription, the row is no longer a live trial, so we move it to past_due, the “billing needs attention” state your hasActiveAccess helper already warns on.
The heart of the handler is one UPDATE, and it is naturally reconciliation-based, which is why it needs no dedup key.
const expired = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });The WHERE matches only rows still trialing whose period has already ended.
The first run flips them to past_due; any later run, duplicate delivery or the next hourly tick, re-evaluates the same predicate, finds nothing, and updates nothing.
The predicate is the idempotency: no claim table, no unique key.
Here’s the full handler, each decision spotlit.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};The verify-first guard from the previous section, factored into isFromVercelCron. Nothing below runs for a request that isn’t Vercel’s scheduler. The trust boundary stays the first decision.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};Compute the comparison instant once. The handler reads “now” through the course’s Temporal seam and converts to a Date only at the Drizzle boundary, the convention from the time chapter. Treat the timestamp as established plumbing; the cron shape is the lesson here.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};Open one transaction. The UPDATE and every audit write commit together or not at all. There’s no external call in this block: audit rows are database writes, so they belong inside tx.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};The predicate is the idempotency. status = 'trialing' AND current_period_end < now matches only live, already-expired trials. The first run flips them to past_due; a second run matches nothing and is a clean no-op. Safe to run twice with no claim row.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};.returning() hands back the org IDs of exactly the rows that changed, giving you both the work and the receipt in one statement. The length of this array is the result the handler reports.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};Write one audit row per expired org, inside the same transaction, but not through logAudit. That helper derives the actor and org from requireOrgUser() and headers(), and a cron has no session. So we insert into auditLogs directly with actorUserId: null, the system actor from The audit log: a null actor is information, not a missing value, recording that a machine, not a person, expired this trial. The payload names which job acted, and the insert rides inside tx, so the audit trail stays atomic with the change it records.
import { and, eq, lt } from 'drizzle-orm';
import { db } from '@/db';import { auditLogs, planEntitlements } from '@/db/schema';import { isFromVercelCron } from '@/lib/cron';import { dateFromInstant, Temporal } from '@/lib/temporal';
export const runtime = 'nodejs';
export const GET = async (request: Request) => { if (!isFromVercelCron(request)) { return new Response('Unauthorized', { status: 401 }); }
const now = dateFromInstant(Temporal.Now.instant());
const expired = await db.transaction(async (tx) => { const rows = await tx .update(planEntitlements) .set({ status: 'past_due' }) .where( and( eq(planEntitlements.status, 'trialing'), lt(planEntitlements.currentPeriodEnd, now), ), ) .returning({ organizationId: planEntitlements.organizationId });
for (const row of rows) { await tx.insert(auditLogs).values({ organizationId: row.organizationId, actorUserId: null, action: 'system.trial-expired', subjectType: 'organization', subjectId: row.organizationId, payload: { source: 'cron:sweep-trials' }, }); } return rows; });
return Response.json({ expired: expired.length });};Return 200 with the count. { expired: 3 } reads straight off the Vercel logs. A best-effort scheduler doesn’t promise to call you, so the logged count is your proof it ran.
One property of this handler marks where the job could outgrow Vercel Cron.
No external calls inside the transaction.
The audit writes are database rows, so they live inside tx.
But suppose product wants to email every org whose trial just expired.
An email send is an external call, so it must move outside the transaction, by the same rule that kept the invitation email out of the transaction in the last chapter.
And at one email per expired row, with potentially thousands of rows, that loop of sends is exactly what blows past the function time limit.
The moment this sweep needs to email, it stops being a pure UPDATE and becomes a fan-out, the threshold that bumps it to a real job runner .
We’ll name that fork in the next section; for now, notice that the shape of the job decides its home.
Two smaller points carry through from earlier chapters.
.returning() does the work and reports it in one statement, the same move as the lifecycle-aware UPDATE in Version columns and 409s: no separate query to find candidates and confirm them, and the count it returns is what you log and alert on.
And the predicate carries the safety, so the sweep needs none of the processed_events machinery the webhook handler did; a job that sent something would, a job that converges a database predicate doesn’t.
The five-field cron expression
Section titled “The five-field cron expression”You’ve written 0 * * * * twice without unpacking it.
A cron expression is five fields, minute, hour, day-of-month, month, and day-of-week, and Vercel scopes it tightly: no extensions, evaluated in UTC, with a few gotchas that fail a deploy.
Here are the five you’ll reach for.
0 9 * * * daily at 09:00 UTC — Hobby-OK (daily is the only Hobby cadence)0 0 * * 0 weekly, Sunday 00:00 UTC — Pro-only (0 = Sunday, numeric)0 0 1 * * monthly, the 1st at 00:00 UTC — Pro-only0 * * * * hourly, on the hour — Pro-only*/15 * * * * every 15 minutes — Pro-onlyThree rules about these expressions are load-bearing.
UTC, always.
Vercel evaluates every cron expression in UTC: there’s no timezone field and no setting to change it.
So 0 9 * * * is 9am UTC, which is 4am in New York, and it drifts by an hour against any local wall-clock twice a year when daylight-saving shifts.
For UTC-anchored work, like the trial sweep or a reconciliation pass, that’s exactly right: “9am UTC every day” is a stable instant.
But a business-hours schedule, like “email the customer at 9am their local time,” is a named threshold a plain UTC cron can’t cross; Trigger.dev’s scheduled tasks solve it, in a later lesson.
Numbers only, no aliases or extensions.
Vercel does not accept MON/SUN or JAN/DEC: Sunday is 0, December is 12, and that’s the only spelling.
There are no L/W/# extensions and no seconds field.
A cron dialect that allowed 0 0 * * MON will fail to deploy here.
Day-of-month and day-of-week are mutually exclusive.
When you put a value in one day field, the other must be *.
You can’t express “the 1st of the month and every Monday” in a single expression; it’s a deploy-time error, not a silent misfire.
If you need both, write two cron entries.
The plan tier is the difference between a deploy that succeeds and one that fails. On the Hobby (free) tier, only once-per-day expressions deploy; anything sub-daily fails the build, and the job may fire anywhere within the hour you specified. On Pro and above, any frequency is allowed and the job fires within the specified minute. Both tiers allow up to 100 cron jobs per project. The course’s app is a Pro deployment, so every sub-daily example here is valid for it.
Where Vercel Cron stops, and what it costs
Section titled “Where Vercel Cron stops, and what it costs”A cron handler is a function invocation, so it inherits every limit a function has, and each limit is a fork that sends a job to a heavier tier. Naming them is what lets you defend “Vercel Cron is enough” and recognize the moment that stops being true. There are four gaps, each tied to the escalation it forces:
- No automatic retries. If your handler returns a 5xx, Vercel logs it and moves on; the run is gone until the next tick. Work that must survive a transient outage on its own schedule, rather than wait an hour, needs a runtime that retries with backoff — a job runner.
- The function-time wall. The handler is bounded by the same 5-minute Hobby / 13-minute Pro wall. A 50,000-user digest that emails recipients one at a time hits that wall and dies mid-send. When the work doesn’t fit one invocation, the cron’s role inverts: it stops doing the work and starts enqueuing it, fanning out to a job runner with no such wall. The cron stays, but shrinks to a trigger.
- Overlapping runs. If a job runs longer than its interval, Vercel can start a second instance while the first is still running, leaving two copies racing. You can make it faster, run it less often, or add a lock — but needing a distributed lock to stop a cron racing itself signals that the work wants a real queue with concurrency control, not a scheduled GET.
- No fan-out, no pauses, no run timeline. One invocation per tick: you can’t pause and resume, wait on a callback, or fan one tick out into a thousand controlled child runs. Your observability is
console.logand the Vercel logs view, stitched together by hand, instead of the run-by-run timeline a job runner gives you for free.
Cost has a counterintuitive shape too. Crons are metered as invocations plus compute, and frequency drives the bill far more than the work does: an every-minute schedule is 43,200 invocations a month, a daily one is 30. A per-minute “check if there’s anything to do” job that almost always finds nothing still costs more than a daily job that does real work. So run a job no more often than its freshness requirement demands — if a digest only needs to be current to the hour, an hourly cron is right and a per-minute one is waste.
All of this rolls up into one decision, and the order you ask the questions in is the whole lesson.
The platform default, and you’re done. A vercel.json entry, a secured GET handler, and idempotent reconciliation work. No second platform, no infra.
The work doesn’t fit one invocation. The next lesson names the five conditions that justify a real job runner, and this is the first of them.
The job must survive a transient failure on its schedule rather than wait an hour. Automatic exponential-backoff retries are a job-runner feature.
The cron stays, but it shrinks to a trigger that enqueues the real work. The fan-out, with concurrency control, runs on the job runner.
Per-tenant or business-hours-local schedules need a timezone-aware scheduler. A later lesson in this chapter builds them.
Notice the order: a senior doesn’t start at “I’ll use a job runner,” but at “does this fit one invocation?” and only climbs when a named property forces it. Each leaf that points away from Vercel Cron names the exact missing thing, which is what lets you say “Vercel Cron is enough, and here’s why” and just as confidently spot the one job in five that needs more.
Running and watching a cron locally
Section titled “Running and watching a cron locally”Vercel does not fire crons against next dev, and there’s no vercel dev support either; the scheduler only ever hits your production deployment.
So you can’t wait for a tick on your laptop, but you don’t need to.
The handler is just a route, so you call it yourself, exactly as Vercel would:
curl -H "Authorization: Bearer $CRON_SECRET" \ http://localhost:3000/api/cron/sweep-trialsWrite the cron as a normal GET route, exercise it locally with curl carrying the bearer header, deploy, then watch the real invocations in Vercel’s Logs view filtered by requestPath:/api/cron/sweep-trials (or hit the cron job’s “View Logs” button in the dashboard).
A curl without the header should come back 401: a five-second check that your trust boundary holds, since a 200 on a bare request means your guard is broken.
Two silent failures live right here. The cron “ran,” nothing happened, and no error tells you:
Check your understanding
Section titled “Check your understanding”This drill targets the decisions that cost the most when you get them wrong.
You’re reviewing a teammate’s first Vercel cron. Select every statement that is correct about how it behaves and how it should be built.
CRON_SECRET should return 401, not the 400 the Stripe webhook returns — it’s missing identity on a private endpoint, not a malformed signature proof.*/15 * * * * schedule fails to deploy, because sub-daily cadences are Pro-only.WHERE status = 'trialing' predicate makes a second run match nothing and change nothing.0 9 * * * can be pinned to the customer’s local 9am.The five correct statements are the load-bearing decisions of the lesson. A cron auth failure is 401 (missing identity on a private door), deliberately different from the webhook’s 400 (malformed proof). Delivery is best-effort — it can miss and duplicate — so handlers reconcile outstanding work, which self-heals both. Vercel does not retry a 5xx cron. Hobby is daily-only; sub-daily expressions fail the deploy. And the trial sweep’s predicate is its idempotency, so no claim row is needed.
The two false statements are the traps. Vercel does not guarantee exactly-once — incrementing a counter per run without a key double-counts on a duplicate delivery. And cron expressions have no timezone field: they’re always UTC, which is exactly why per-tenant local-time schedules need a different tool.
That’s the tier-1 floor, and it’s higher than most people expect from “just a cron.”
Next, When a workload needs a job platform names the five conditions that justify reaching past the platform for a real job runner.
External resources
Section titled “External resources”The canonical reference — the vercel.json crons shape, the plan-tier cadence limits, and the production-only delivery behavior this lesson is built on.
Vercel's guide to the Authorization: Bearer CRON_SECRET pattern the trust boundary in this lesson hardens with a constant-time compare.
Translate any five-field cron expression into plain English as you type — the standard tool for sanity-checking a schedule before you ship it.