Heartbeats
How to monitor Vercel cron jobs
A Vercel cron job can stop running without your application going down. Send OnlineOrNot a heartbeat after the work finishes, so a failed job or a missing invocation leaves a gap you can alert on.
This guide assumes you already have a Next.js App Router project and a working job function. It adds completion monitoring, not a second scheduler. Vercel still invokes the job.
1. Create a heartbeat with enough grace
Create a heartbeat with a 24-hour reporting period and a 70-minute grace period for the daily example below. Configure an alert destination someone will watch.
The grace period matters on Hobby. Vercel allows one invocation per day and may invoke a job anywhere within its scheduled hour. A job scheduled for 08:00 UTC could start at 08:59:59. A five-minute grace period can therefore alert even when the scheduler is working as documented.
The 70-minute example allows for nearly an hour of scheduling variation, a function budget of one minute, and delivery margin. The route below sets maxDuration to 60 seconds, including the heartbeat request. Check that your Vercel plan permits that runtime and that the work finishes with time left for the heartbeat. Choose a different grace period if your runtime budget differs. See Vercel's cron limits.
This uses a fixed interval from accepted pings, not an alert deadline of 09:10 UTC. A daily job can take over a day to be reported missing. Use a suitable paid-plan frequency if you need a shorter detection window. OnlineOrNot's deadline processing adds some delay; grace is not a delivery-time guarantee.
2. Set two production environment variables
In your Vercel project, configure these for the Production environment and deploy the change:
CRON_SECRET: a random value of at least 16 characters. Vercel sends it asAuthorization: Bearer ...when invoking the job. The route must check it.OON_HEARTBEAT_URL: the secret ping URL from your new heartbeat.
These are separate credentials. Do not prefix either with NEXT_PUBLIC_, put them in vercel.json, commit them, or print them in logs. Anyone with the heartbeat URL can send a success ping, so use a dedicated heartbeat for this job.
Vercel's cron management documentation explains authorization and invocation behavior.
3. Ping only after the awaited work succeeds
Create app/api/cron/daily-report/route.js. Replace the job import with your existing job module; it is not a function supplied by OnlineOrNot.
import { runDailyReport } from '@/lib/jobs/daily-report';
export const runtime = 'nodejs';
export const maxDuration = 60;
/** Await the job before reporting success; never ping on a rejected invocation. */
export async function GET(request) {
const secret = process.env.CRON_SECRET;
if (!secret || request.headers.get('authorization') !== `Bearer ${secret}`) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const heartbeatUrl = process.env.OON_HEARTBEAT_URL;
if (!heartbeatUrl) {
return Response.json(
{ error: 'Heartbeat not configured' },
{ status: 500 },
);
}
try {
await runDailyReport();
} catch {
return Response.json({ error: 'Job failed' }, { status: 500 });
}
try {
const ping = await fetch(heartbeatUrl, {
method: 'POST',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(5000),
});
if (!ping.ok) {
return Response.json(
{ error: 'Job finished; heartbeat rejected' },
{ status: 502 },
);
}
} catch {
return Response.json(
{ error: 'Job finished; heartbeat delivery failed' },
{ status: 502 },
);
}
return Response.json({ ok: true });
}
The missing-secret check is deliberate: an unset CRON_SECRET must not allow the job to run. A missing heartbeat URL also stops execution rather than silently doing unmonitored work.
runDailyReport() must reject if the required work fails. It must not swallow an error, return an unsuccessful result as a normal value, or start detached work and resolve early. Otherwise the route will report success too soon. If the job returns a result instead of throwing, check that result before pinging.
Do not move the ping into finally, send it at the start, mark this route as static, or serve it from a cache. A request to the route is not proof that the job completed.
A heartbeat-delivery failure is different from a job failure: the work may already be committed. This example does not rerun the job when the ping fails. Vercel does not automatically retry a failed cron invocation either. Before manually retrying, check whether the work completed and whether repeating it is safe.
For jobs with side effects, use application-level idempotency and a shared lock where necessary. Vercel documents possible concurrent invocations. A variable in a serverless process cannot reliably prevent another instance from doing the same work. This monitoring wrapper does not provide locking or exactly-once execution.
4. Configure the daily invocation
Add this entry to your existing vercel.json, preserving other configuration:
{
"crons": [
{
"path": "/api/cron/daily-report",
"schedule": "0 8 * * *"
}
]
}
Vercel cron schedules use UTC. This expression requests one daily invocation in the 08:00 UTC hour. Do not copy a per-minute schedule onto Hobby; Vercel rejects schedules that exceed its daily limit.
Deploy to production, then confirm the job appears under your project's Settings → Cron Jobs. Preview deployments and a local dev server are not evidence that a production schedule is running.
5. Verify the first success, then failure and recovery
Run the job through Vercel's cron controls or wait for a scheduled invocation. Use a job that is safe to run manually. Confirm its logs show successful completion and that the heartbeat's Last seen updates and its state becomes Up.
A new heartbeat stays Pending until its first accepted ping. If the initial invocation fails, fix it before relying on missing-run alerts.
Rehearse with a separate test job and its own heartbeat, not by breaking real production work:
- Run the test job successfully and confirm its heartbeat is Up. No other job should ping that URL.
- Make that test job reject before completion. Confirm its route returns an error and no success ping arrives.
- Leave it without pings until the reporting period and grace expire. Confirm Down and delivery of the intended alert. An isolated rehearsal can use a shorter heartbeat interval, but restore the production settings before relying on it.
- Restore the test job and invoke it successfully. Confirm Up and a recovery notification.
If a real alert arrives, check the job logs first. Missing authorization, a function timeout, a removed schedule, failed work, and a failed heartbeat request can all produce the same missing-ping symptom. The heartbeat tells you that completion was not reported, not which of those causes occurred.