Serverless apps don't have a long-running machine where you can open a crontab. But you still need nightly cleanups, hourly syncs and Monday reports. Every major platform now has its own way to run a function on a schedule. They look similar, but they differ in configuration, security and what happens when a run fails.
Here's how to set up a scheduled job on Vercel, Cloudflare Workers and Netlify, and what to watch out for on each.
Vercel Cron Jobs
On Vercel, a cron job is a schedule that calls one of your routes. You declare it in vercel.json:
{
"crons": [
{ "path": "/api/cron/cleanup", "schedule": "0 3 * * *" }
]
}
Then write the route as a normal function. In a Next.js App Router project:
// app/api/cron/cleanup/route.ts
export async function GET(request: Request) {
const auth = request.headers.get('authorization');
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('Unauthorized', { status: 401 });
}
const removed = await deleteExpiredSessions();
return Response.json({ removed });
}
Things to know:
-
Secure it with
CRON_SECRET. Add an environment variable calledCRON_SECRETto the project, and Vercel sends it as aBearertoken in theAuthorizationheader on cron invocations. Without the check, anyone who finds the path can run your job. -
Schedules are in UTC.
0 3 * * *is 3:00 UTC. - Production only. Crons run against the production deployment, not previews.
- Plan limits apply. The number of cron jobs and how precisely they fire depend on your plan. Check the current limits before you design around a schedule like every five minutes.
- It's a normal function invocation. The usual function duration limits apply, and a failed run isn't automatically retried.
Cloudflare Workers Cron Triggers
On Cloudflare, cron triggers call a scheduled handler in your Worker rather than an HTTP route. Configure them in wrangler.toml:
name = "nightly-jobs"
main = "src/index.ts"
compatibility_date = "2026-01-01"
[triggers]
crons = ["0 3 * * *", "*/30 * * * *"]
Then export a scheduled handler:
export default {
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
switch (controller.cron) {
case '0 3 * * *':
ctx.waitUntil(runNightlyCleanup(env));
break;
case '*/30 * * * *':
ctx.waitUntil(syncInventory(env));
break;
}
},
};
Things to know:
- No public URL. The handler isn't reachable over HTTP, so there's nothing to secure with a secret. That's a nice property.
-
controller.crontells you which schedule fired, so one Worker can serve several schedules. - Schedules are in UTC.
-
Test locally with
npx wrangler dev --test-scheduled. The dev server then exposes a route you can request to trigger the handler; the wrangler output shows the URL to use. -
Use
ctx.waitUntil()for async work so the runtime doesn't end the invocation early. - Past runs appear in the Worker's logs and metrics in the dashboard. Check there first when a job seems not to run.
Netlify Scheduled Functions
Netlify lets you attach a schedule to a function in code:
// netlify/functions/daily-digest.mts
import type { Config } from '@netlify/functions';
export default async (req: Request) => {
await sendDailyDigest();
return new Response('ok');
};
export const config: Config = {
schedule: '0 8 * * *',
};
Shortcuts like @hourly and @daily are also accepted. Like the others, schedules run in UTC, they only run on published deploys, and scheduled functions have a shorter execution limit than regular ones. For longer work, have the scheduled function hand off to a background function or a queue.
The common limits
All three platforms solve "run this code on a schedule". None of them is a full job system. Plan for these gaps:
1. UTC only. If a job must run at 9:00 in a specific time zone all year, either accept a DST shift twice a year, or schedule hourly and check the local hour inside the function:
const hour = Number(new Intl.DateTimeFormat('en-GB', {
hour: 'numeric', hourCycle: 'h23', timeZone: 'Europe/Berlin',
}).format(new Date()));
if (hour !== 9) return new Response('skip');
2. Timeouts. Scheduled functions share the platform's execution limits. Process work in batches, save a cursor, and pick up where you left off on the next run.
3. No retries. If the run throws, you usually find out later, if at all. Catch errors, log them, and send yourself an alert:
try {
await syncInventory(env);
} catch (err) {
await fetch(env.ALERT_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `syncInventory failed: ${String(err)}` }),
});
throw err;
}
4. Overlap. If a run takes longer than the interval, two can run at once. Use a lock (a row in your database, a key in KV with an expiry) or make the job idempotent.
5. Vendor lock-in. Your schedules live in platform config. Moving hosts means rewriting them.
When to use an external scheduler instead
If your job is already an HTTP endpoint, you can keep the schedule outside your hosting platform entirely. A hosted scheduler calls the endpoint on a cron, so the same setup keeps working on Vercel, Cloudflare, Render, Fly or your own servers.
delay.run is one option built for this: it calls your URL on a standard five-field cron or after a delay, logs every run with its request, response and latency, retries failed one-off jobs with backoff, and alerts by email, Slack or Discord when a cron fails several times in a row. Requests are HMAC-signed, so your endpoint can verify they came from the scheduler.
Which should you pick?
- One or two simple jobs on Vercel or Netlify: use the built-in cron. Add the auth check and error alerts.
- Cloudflare Workers: Cron Triggers are excellent, with no public URL and multiple schedules per Worker.
- Jobs that must not fail silently, or you're on several platforms: use an external scheduler with logs, retries and alerts, and keep your endpoints portable.
Whichever you choose, write the job so that it can safely run twice, finish within the time limit, and tell you when it fails. That covers most of what can go wrong with scheduled code on serverless.
Top comments (1)
We hit the cron problem at a smaller scale: the worker occasionally ran twice because the scheduler retried during a deploy. Ended up storing a "last run" row with a unique constraint and letting the insert itself be the lock. Boring, but it hasn't fired twice since.