Cron Jobs Lab
Inspect Vercel Cron schedules and persist heartbeat runs to Redis or Blob.
Run this experiment yourself
Demos are not embedded on this site. Deploy a standalone copy on Vercel or run the experiment app locally.
Local development
cd apps/experiments/cron-jobs-lab pnpm install pnpm dev
Then open http://localhost:3010.
This is an experimental demo. Use it as a starting point for your own projects.
The Cron Jobs Lab shows how Vercel Cron invokes a Route Handler on a schedule. A cron entry in vercel.json calls /api/cron/heartbeat at minute 0 of every hour. The handler authorizes the request, records a heartbeat payload to Upstash Redis (or Vercel Blob when Redis is absent), and a companion status route reports the last recorded run so the demo can display it.
Features
- Real schedule.
vercel.jsonregisters/api/cron/heartbeatwith0 * * * *(hourly at minute 0). The demo describes the expression in plain language. - Run now. The Run heartbeat now button calls the heartbeat route directly so you can see the persistence path without waiting for the scheduler.
- Status view.
GET /api/cron/statusreturns the configured schedule, route, storage backend, and the last heartbeat when Redis is available. - Storage fallback. The heartbeat writes to Redis first, then Blob, then nothing (
ephemeral). - Authorization. Production requests are checked against
CRON_SECRETwhen it is set. - One-click provisioning. The Deploy button provisions Redis and Blob and prompts for
CRON_SECRET.
API Reference
GET /api/cron/heartbeat
Records one heartbeat. POST is also exported and simply calls the same handler.
Authorization
- If
CRON_SECRETis set, the request must sendAuthorization: Bearer <CRON_SECRET>. Vercel Cron includes this header automatically when the variable is configured in the project. - If
CRON_SECRETis not set, the request is allowed when theuser-agentcontainsvercel-cron, or whenNODE_ENVis notproduction.
Prop
Type
Storage precedence:
Prop
Type
GET /api/cron/status
Public, read-only status for the demo. It has no authorization check and never returns 503.
Prop
Type
Implementation Details
Register the schedule
Vercel reads cron jobs from vercel.json and invokes the path with an HTTP GET in production.
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{
"path": "/api/cron/heartbeat",
"schedule": "0 * * * *"
}
]
}Authorize the caller
function authorize(request: Request): boolean {
const secret = process.env.CRON_SECRET?.trim();
if (!secret) {
// On Vercel, Cron invocations include Authorization when CRON_SECRET is set.
// Without a secret, allow only Vercel Cron user-agent in production-like hosts.
const ua = request.headers.get('user-agent') ?? '';
return ua.includes('vercel-cron') || process.env.NODE_ENV !== 'production';
}
const auth = request.headers.get('authorization');
return auth === `Bearer ${secret}`;
}Persist to Redis, then Blob
const redis = getRedis();
if (redis) {
await redis.set(HEARTBEAT_KEY, payload, { ex: 60 * 60 * 48 });
storage = 'redis';
} else if (getServiceStatus('blob').available) {
await put('experiments/cron/heartbeat.json', JSON.stringify(payload, null, 2), {
access: 'public',
addRandomSuffix: false,
allowOverwrite: true,
contentType: 'application/json',
});
storage = 'blob';
}
return NextResponse.json({ ok: true, ...payload, storage });Report status
GET /api/cron/status reads the last run from Redis when it can, and otherwise returns a note explaining the fallback.
const redis = getRedis();
if (redis) {
const last = await redis.get<{ ranAt: string; path: string; schedule: string }>(
HEARTBEAT_KEY,
);
return NextResponse.json({
configured: true,
storage: 'redis',
last,
schedule: '0 * * * *',
route: '/api/cron/heartbeat',
});
}Describe the schedule in the UI
apps/experiments/cron-jobs-lab/logic.ts maps a few known expressions to readable text and falls back to Custom cron expression for anything else.
export function describeCronSchedule(expression: string): string {
if (expression === '0 * * * *') return 'Once every hour at minute 0';
if (expression === '*/5 * * * *') return 'Every 5 minutes';
if (expression === '0 0 * * *') return 'Daily at 00:00 UTC';
return 'Custom cron expression';
}Use Cases
- Scheduled cleanup of expired records, cache entries, or temporary files.
- Periodic syncs, digests, and report generation.
- Health and liveness heartbeats for dashboards and alerts.
- Warming caches or refreshing data that is read frequently.
Limitations
- Run now needs development or matching auth. The button calls the heartbeat route from the browser with no
Authorizationheader and a browser user-agent. In production this returns401(whether or notCRON_SECRETis set), and the demo shows the error message. The hourly scheduler still works because Vercel Cron sends the right headers. - Cron only runs on Vercel deployments.
pnpm devdoes not run the scheduler; trigger the route manually withcurl. - Fixed schedule. The schedule is a literal in
vercel.jsonand in the route payload. The UI describes it but does not edit it. - Status is Redis-centric. With Blob storage only, the status route reports
last: nulleven though the heartbeat object exists. - Ephemeral mode persists nothing. Without Redis or Blob the handler still returns
200 ok. - No retries or locking. The handler does not deduplicate overlapping runs or retry failures. Make real jobs idempotent.
- Public status.
GET /api/cron/statusis unauthenticated, and Blob heartbeats are written withaccess: "public".
Use in your project
A protected cron route and its schedule. Set CRON_SECRET in your project so Vercel sends the bearer header.
{
"crons": [{ "path": "/api/cron/cleanup", "schedule": "0 0 * * *" }]
}// app/api/cron/cleanup/route.ts
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET?.trim();
if (!secret || request.headers.get('authorization') !== `Bearer ${secret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Do idempotent work here.
return NextResponse.json({ ok: true, ranAt: new Date().toISOString() });
}Deployment
Use the one-click Deploy button. It clones the repository, provisions Upstash Redis and a public Blob store, and prompts for CRON_SECRET.
After deploying, the schedule from vercel.json appears under your project's Cron Jobs settings.
Local Development
pnpm install
pnpm devCron does not fire locally, so trigger the route yourself. With no CRON_SECRET set, any request is accepted outside production:
# Run the heartbeat
curl http://localhost:3000/api/cron/heartbeat
# Check the last run
curl http://localhost:3000/api/cron/statusWith CRON_SECRET set in .env.local, send the bearer header:
curl http://localhost:3000/api/cron/heartbeat \
-H "authorization: Bearer $CRON_SECRET"An unauthorized call returns:
{ "error": "Unauthorized" }With neither Redis nor Blob configured, the heartbeat still succeeds but reports "storage": "ephemeral", and /api/cron/status returns storage: "none" with a note explaining that the heartbeat persists to Redis when available.
Configuration
From .env.example:
| Variable | Required | Purpose |
|---|---|---|
CRON_SECRET | Recommended in production | Shared secret checked against Authorization: Bearer <secret>. Vercel Cron sends it automatically when set. |
UPSTASH_REDIS_REST_URL | Optional | Preferred heartbeat storage. |
UPSTASH_REDIS_REST_TOKEN | Optional | Preferred heartbeat storage. |
BLOB_READ_WRITE_TOKEN | Optional | Fallback heartbeat storage when Redis is absent. |
No variable is strictly required to run the demo: requiredEnvironmentVariables is empty because the lab degrades to ephemeral.
Vercel / Next.js Features Used
- Vercel Cron configured in
vercel.json. - Route Handlers with
GETandPOSTexports. - Upstash Redis from the Vercel Marketplace (
SETwith expiry,GET). - Vercel Blob (
putwithallowOverwrite) as a fallback store. - Deploy Button
storesandenvparameters (REDIS_STORE,BLOB_STOREinlib/vercel/deploy.ts).