Skip to content
Chapter 66Lesson 2

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.

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.

Vercel Scheduler fires on a cadence
GET /api/cron/sweep-trials a public path you own
Route handler one invocation → returns
A cron is an external scheduler making a timed 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-trials can 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.

A cron is two artifacts that point at each other: a config entry that declares when, and a route handler that declares what.

vercel.json
{
"$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.

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

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.

1 / 1

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:

src/env.ts
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 WHERE clause, 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.

Safe to run twice The predicate or the math converges — a re-run is a no-op
Needs a dedup key / claim A duplicate is visible to a human or doubles a value
UPDATE trials SET status='past_due' WHERE status='trialing' AND current_period_end < now()
Recompute each org’s seat count from its current member rows
Set every overdue invoice’s display flag to overdue
Send the weekly digest email to every active org
INSERT one usage-rollup row for last week
Charge each past-due invoice’s card

The “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.

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.

1 / 1

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.

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-only
0 * * * * hourly, on the hour — Pro-only
*/15 * * * * every 15 minutes — Pro-only

Three 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.log and 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.

I have a recurring job — where does it live?

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.

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:

Terminal window
curl -H "Authorization: Bearer $CRON_SECRET" \
http://localhost:3000/api/cron/sweep-trials

Write 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:

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.

A request to the cron route with no matching 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.
Because delivery is best-effort, the handler must be idempotent and catch-up-based — a run can be both missed and duplicated, so the handler reconciles all outstanding work rather than acting on a remembered delta.
If the handler returns a 5xx, Vercel logs it but does not retry — a failed cron is simply gone until the next tick.
On the Hobby tier, a */15 * * * * schedule fails to deploy, because sub-daily cadences are Pro-only.
The trial-sweep UPDATE needs no dedup key, because its WHERE status = 'trialing' predicate makes a second run match nothing and change nothing.
Vercel guarantees each cron fires exactly once, so the handler can safely increment a counter on every run without a dedup key.
Cron expressions can use a timezone field, so 0 9 * * * can be pinned to the customer’s local 9am.

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.