Why a Vercel cron job can skip a run with no error
Vercel's docs say some scheduled runs never reach your code, and then your logs have nothing to show:
Cron job delivery is best effort. Most invocations run as scheduled, but occasional transient network errors can prevent a request from reaching your function. In those cases, your function does not execute, and no runtime log is created for that scheduled run.
Vercel will not retry an invocation if a cron job fails.
Your code can't report a run it never saw. Sereno knows when each run is due, so when no report arrives in time, it marks the run missed and alerts you.
Before you start
- A check in Sereno for this job, with the schedule from your vercel.json (one check for each cron there). Vercel's docs say “The timezone is always UTC”, so set the check's time zone to UTC. Make it with New check on your dashboard, which takes the same five-field cron expression, or let your AI agent set it up.
- A grace period of 1 hour plus the job's longest run time. Grace is how late a run's report can come before the run counts as missed, and the report line below sends its report only when the job ends. On Hobby plans, Vercel's docs say Vercel “may invoke these cron jobs at any point within the specified hour”, so add a few minutes more there.
- Your project's report address, the value for ALERTS_URL. It looks like
https://asksereno.com/k/PROJECT_KEY, with no check slug at the end. The check's page shows it under This project's report address, ready to copy.
Add the report line
Save this project's report address as an environment variable named ALERTS_URL (Project → Settings → Environment Variables), or with the Vercel CLI, which asks for it. Then redeploy: push to the production branch, or run vercel --prod.
vercel env add ALERTS_URL productionvercel env add ALERTS_URL production
In the route your vercel.json cron calls, wrap the job so it always reports: ok, or fail with the error. Hobby plans start crons anywhere in the hour. Vercel runs cron schedules in UTC. While Sereno can't be reached, report() keeps trying for up to about 2 minutes, so leave room for that in the function's time limit (maxDuration).
// The route your cron calls, e.g. app/api/cron/route.ts. If it checks CRON_SECRET, keep its request // parameter and that check first. On Next.js 13 or 14, also put export const dynamic = "force-dynamic"; // above it. Without that line, Next.js runs the job once while building and answers every cron call // with that saved result. export async function GET() { const rid = crypto.randomUUID(); // one id per run try { await runJob(); // your job's code await report("ok", "done", rid); return new Response("ok"); } catch (err) { await report("fail", err instanceof Error ? (err.stack ?? err.message) : String(err), rid); return new Response("failed", { status: 500 }); } } // Sends this run's result to Sereno: kind is "ok" or "fail", and note says what happened. While // Sereno can't be reached (say, it's restarting), it tries again up to 5 times, 10 s apart, with // the same rid, so a report sent twice counts once. The defaults only give TypeScript the types. async function report(kind = "ok", note = "", rid = "") { const base = (process.env.ALERTS_URL ?? "").replace(/\/+$/, ""); if (!base) return console.error("ALERTS_URL is not set: this run wasn't reported"); for (let i = 0; i <= 5; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 10_000)); try { const res = await fetch(`${base}/CHECK_SLUG/${kind}?rid=${rid}`, { method: "POST", body: note.slice(-2000), signal: AbortSignal.timeout(10_000) }); if (res.status < 500) return; } catch {} } }// The route your cron calls, e.g. app/api/cron/route.ts. If it checks CRON_SECRET, keep its request // parameter and that check first. On Next.js 13 or 14, also put export const dynamic = "force-dynamic"; // above it. Without that line, Next.js runs the job once while building and answers every cron call // with that saved result. export async function GET() { const rid = crypto.randomUUID(); // one id per run try { await runJob(); // your job's code await report("ok", "done", rid); return new Response("ok"); } catch (err) { await report("fail", err instanceof Error ? (err.stack ?? err.message) : String(err), rid); return new Response("failed", { status: 500 }); } } // Sends this run's result to Sereno: kind is "ok" or "fail", and note says what happened. While // Sereno can't be reached (say, it's restarting), it tries again up to 5 times, 10 s apart, with // the same rid, so a report sent twice counts once. The defaults only give TypeScript the types. async function report(kind = "ok", note = "", rid = "") { const base = (process.env.ALERTS_URL ?? "").replace(/\/+$/, ""); if (!base) return console.error("ALERTS_URL is not set: this run wasn't reported"); for (let i = 0; i <= 5; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 10_000)); try { const res = await fetch(`${base}/CHECK_SLUG/${kind}?rid=${rid}`, { method: "POST", body: note.slice(-2000), signal: AbortSignal.timeout(10_000) }); if (res.status < 500) return; } catch {} } }
CHECK_SLUG stands for your check's slug, which Sereno makes from the check's name. The report line adds it to ALERTS_URL itself, so the variable holds only your project's report address. On the check's page, these lines have the slug filled in.
If your route checks CRON_SECRET, as Vercel's docs show, keep that check at the top. Give GET its request, and put the check before the job's lines, like this:
// The route your cron calls, e.g. app/api/cron/route.ts. If it checks CRON_SECRET, keep its request // parameter and that check first. On Next.js 13 or 14, also put export const dynamic = "force-dynamic"; // above it. Without that line, Next.js runs the job once while building and answers every cron call // with that saved result. export async function GET(request: Request) { const authHeader = request.headers.get("authorization"); const cronSecret = process.env.CRON_SECRET; if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { return new Response("Unauthorized", { status: 401 }); } const rid = crypto.randomUUID(); // one id per run try { await runJob(); // your job's code await report("ok", "done", rid); return new Response("ok"); } catch (err) { await report("fail", err instanceof Error ? (err.stack ?? err.message) : String(err), rid); return new Response("failed", { status: 500 }); } } // Sends this run's result to Sereno: kind is "ok" or "fail", and note says what happened. While // Sereno can't be reached (say, it's restarting), it tries again up to 5 times, 10 s apart, with // the same rid, so a report sent twice counts once. The defaults only give TypeScript the types. async function report(kind = "ok", note = "", rid = "") { const base = (process.env.ALERTS_URL ?? "").replace(/\/+$/, ""); if (!base) return console.error("ALERTS_URL is not set: this run wasn't reported"); for (let i = 0; i <= 5; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 10_000)); try { const res = await fetch(`${base}/CHECK_SLUG/${kind}?rid=${rid}`, { method: "POST", body: note.slice(-2000), signal: AbortSignal.timeout(10_000) }); if (res.status < 500) return; } catch {} } }// The route your cron calls, e.g. app/api/cron/route.ts. If it checks CRON_SECRET, keep its request // parameter and that check first. On Next.js 13 or 14, also put export const dynamic = "force-dynamic"; // above it. Without that line, Next.js runs the job once while building and answers every cron call // with that saved result. export async function GET(request: Request) { const authHeader = request.headers.get("authorization"); const cronSecret = process.env.CRON_SECRET; if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) { return new Response("Unauthorized", { status: 401 }); } const rid = crypto.randomUUID(); // one id per run try { await runJob(); // your job's code await report("ok", "done", rid); return new Response("ok"); } catch (err) { await report("fail", err instanceof Error ? (err.stack ?? err.message) : String(err), rid); return new Response("failed", { status: 500 }); } } // Sends this run's result to Sereno: kind is "ok" or "fail", and note says what happened. While // Sereno can't be reached (say, it's restarting), it tries again up to 5 times, 10 s apart, with // the same rid, so a report sent twice counts once. The defaults only give TypeScript the types. async function report(kind = "ok", note = "", rid = "") { const base = (process.env.ALERTS_URL ?? "").replace(/\/+$/, ""); if (!base) return console.error("ALERTS_URL is not set: this run wasn't reported"); for (let i = 0; i <= 5; i++) { if (i > 0) await new Promise((r) => setTimeout(r, 10_000)); try { const res = await fetch(`${base}/CHECK_SLUG/${kind}?rid=${rid}`, { method: "POST", body: note.slice(-2000), signal: AbortSignal.timeout(10_000) }); if (res.status < 500) return; } catch {} } }
In a .js file, leave out : Request.
Then deploy the new code to production: push to your production branch, or run vercel --prod.
Send a test report
From a terminal on your own computer, run this. The check's page in Sereno shows this line with your project's key and your check's slug filled in, under Check it can reach us.
curl -fsS -m 10 https://asksereno.com/k/PROJECT_KEY/CHECK_SLUG/testcurl -fsS -m 10 https://asksereno.com/k/PROJECT_KEY/CHECK_SLUG/test
It prints OK. On the check's page, Test report gets a tick. A test report never changes the check's status.
That proves the address and the slug are right. Vercel's side shows on the first scheduled run: Real run gets a tick, and the check shows OK, or Failing if the run failed. To see it sooner, Vercel's CLI can “trigger a cron job to run immediately for testing”. It only finds a cron job already deployed to production, and it runs your job for real:
vercel crons run /api/cronvercel crons run /api/cron
Put your cron's path from vercel.json in place of /api/cron. Vercel lists this command as beta.
If it doesn't work
The test fails
- The test prints
curl: (22) The requested URL returned error: 404. Sereno doesn't know that address. Open the project's page in Sereno: if the test shows under Reports for checks that don't exist, the slug is wrong. If it doesn't show there, the project key is wrong. - The test prints error 429. That check got more than 60 reports in a minute: wait a minute and try again. An error of 500 or above means Sereno couldn't answer just then, so try again in a few minutes. Every answer a report address gives is in the docs.
The test works, but scheduled runs don't report
- No report from any scheduled run. Vercel's troubleshooting guide says “Cron jobs only run on Production deployments.” Check that your latest production deployment has the new code and ALERTS_URL. Redeploy after adding the variable, as the first step says.
- The route's logs show “ALERTS_URL is not set: this run wasn't reported”. The report line wrote that because ALERTS_URL isn't set for this deployment. Add it, then redeploy.
- Runs show as missed, but Vercel's logs show the job ran. The slug in your code may not match the one you tested, and the report line ignores a 404 without logging it. Open the project's page in Sereno: a wrong slug shows under Reports for checks that don't exist. Fix CHECK_SLUG in the code, or ALERTS_URL if nothing shows there, then deploy again.
- No log at all for a scheduled run. Vercel's docs say that “when cron jobs respond with a redirect or a cached response, they will not be shown in the logs.” Vercel's troubleshooting guide adds “If you're not seeing logs for your cron job, it might be due to caching.” For a Next.js route, it suggests
export const dynamic = 'force-dynamic';so the route runs every time. - No runs since someone turned cron jobs off in Settings → Cron Jobs. Vercel's docs say “If you disable cron jobs, they remain disabled until you explicitly re-enable them in the project's Cron Jobs settings.”
- Sereno counted a run as missed, then its report came late. Raise the check's grace period in its Settings.
The short version of this setup, with the other platforms', is in Sereno's setup recipes.
Vercel's docs quoted as read on October 26, 2026.
More guides
- Ask your AI agent why a cron job failed
- Monitor GitHub Actions scheduled workflows
- Monitor Supabase cron jobs that call Edge Functions
- Get an alert when a cron job fails or doesn't run
- Healthchecks.io alternative with AI agent setup
- Cronitor alternative with 200 free checks
New here? Sign up with your email. Free: up to 200 checks, 50 alert emails a day, and phone alerts through ntfy.