Scheduled Jobs and Background Work on Vercel: A Technical Guide
Serverless is built for handling requests: someone hits a URL, a function wakes up, responds, and goes back to sleep. But real applications also need work that happens away from any request. A nightly report has to generate itself, an abandoned cart needs a reminder, data has to sync on a schedule, a subscription has to be charged at the right time. None of that is triggered by a user clicking a button, and that is where a lot of otherwise solid Vercel apps get stuck.
The good news is that Vercel has the pieces for this, and they are not complicated once you understand the one constraint that shapes the whole design. This guide covers how to run scheduled and background work properly on Vercel, with code you can adapt.
The constraint that shapes everything: functions are short-lived
Serverless functions have a maximum execution time. They are meant to start, do a focused piece of work, and finish quickly, which is perfect for a request and a problem for a job that needs ten minutes to churn through ten thousand records. If you try to do all of that inline, the function hits its limit and gets killed partway through, leaving the work half-done.
So the governing principle is this: keep every individual run short, and break long work into many small units. Almost every pattern below is a way to honor that one rule. Once you design around it instead of fighting it, the rest falls into place.
Scheduled work: Vercel Cron
For anything that runs on a clock, Vercel Cron is the native tool. You declare schedules in vercel.json, each pointing at a route, and Vercel calls that route on standard cron syntax.
{ "crons": [{ "path": "/api/cron/nightly", "schedule": "0 3 * * *" }] }
Because the path is a normal URL, protect it so only the scheduler can run it, by checking the bearer token Vercel sends from your CRON_SECRET. And keep the handler itself tiny: rather than doing the heavy work in the cron run, use it to find what needs doing and hand each piece off, so no single invocation risks the time limit.
// the cron route stays small: it fans work out instead of doing it all inline
export async function GET(req: Request) {
if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response("unauthorized", { status: 401 });
}
const ids = await db.pendingReportIds();
await Promise.all(ids.map((id) => queue.enqueue({ id }))); // one short job each
return new Response("ok");
}
Long or heavy work: offload to a queue
When a unit of work might exceed the function limit, or simply must not be lost if it fails, a queue is the answer. Instead of processing inline, you publish a message, and the queue calls back a worker route to handle it, one short job at a time, retrying automatically if it fails. A managed option like Upstash QStash fits Vercel well because it is just HTTP: you publish to a URL, and it delivers to your endpoint.
This turns one impossible ten-minute job into a thousand ten-millisecond ones, each comfortably inside the limit, each retried on its own if something goes wrong. The cron run becomes a dispatcher and the queue becomes the engine, which is exactly the shape serverless wants.
Do not let jobs overlap: locking
A subtle bug bites scheduled work: a job that runs every few minutes can start a new run before the previous one finished, and now two copies are processing the same records, sending the same email twice or double-charging a card. The fix is a lock that lets only one run proceed at a time.
A single atomic operation does it. Set a key only if it does not already exist, with a time-to-live so the lock releases itself even if a run dies mid-way and never cleans up.
// only one runner at a time; the TTL frees the lock if a run dies
const ok = await redis.set("lock:nightly", "1", { nx: true, ex: 600 });
if (!ok) return; // another run holds the lock, skip this tick
try {
await doWork();
} finally {
await redis.del("lock:nightly");
}
Make jobs idempotent and resumable
Background jobs get retried and sometimes killed partway through, so design each one to be safe to run again. That means idempotent writes, deduping on stable ids and using upserts, so a re-run does not duplicate what a previous attempt already did.
For larger jobs, process in batches and checkpoint your progress, recording how far you got, so a retry resumes from the last checkpoint instead of starting over or doubling up. The combination, small idempotent units plus a saved cursor, is what makes a long job survive being interrupted, which on serverless it eventually will be.
Observe: logging, alerting, and the job that never ran
Background work is invisible by nature, which is its real danger. A failing API throws an error someone sees, but a cron job that silently stops running produces nothing at all, and you only notice when the report that should have arrived did not. So watch for absence as much as failure.
Log each run with its start, end, duration, and outcome. Alert when a job fails repeatedly, when a backlog grows, and, crucially, when an expected run did not happen at all, a simple heartbeat check that flags "the nightly job has not succeeded in 25 hours" catches the failures that produce no error. Route work that fails permanently to a dead-letter store so it waits for a human instead of retrying forever.
A background-jobs checklist
- Assume functions are short-lived, keep every run small and break long work into units.
- Use Vercel Cron for schedules, guard the route with
CRON_SECRET, and keep the handler a dispatcher. - Offload heavy or must-not-fail work to a queue with automatic retries.
- Use a lock with a TTL so scheduled runs never overlap.
- Make jobs idempotent, process in batches, and checkpoint progress so retries resume.
- Log start, end, and duration, and alert on failures, backlog, and missed runs.
- Dead-letter anything that fails permanently so it is inspected, not lost.
FAQ
How do I run a cron job on Vercel?
Add a crons entry to vercel.json with a path and a standard cron schedule, and Vercel will call that route at the set times. Protect the route by verifying the bearer token Vercel sends from your CRON_SECRET environment variable, since the path is otherwise a public URL. Keep the handler short, ideally using it to dispatch work to a queue rather than doing long processing inline.
My job takes longer than the function limit, what do I do?
Stop trying to do it in one run. Break the work into many small units and process them through a queue, where each message is a short job that fits inside the function limit and retries on its own if it fails. The scheduled run becomes a dispatcher that enqueues the pieces, and the queue works through them. Batching with saved checkpoints lets even very large jobs complete across many short invocations.
How do I stop a scheduled job from running twice?
Use a lock. Before a run starts, atomically set a key only if it does not already exist, with a time-to-live, and skip the run if the key is already held. The TTL ensures the lock releases itself if a run dies without cleaning up, so you never deadlock. This prevents a new scheduled run from overlapping one that is still in progress and processing the same records twice.
How do I know if a scheduled job failed or never ran?
Build for absence, not just errors. Log every run's outcome and add a heartbeat check that alerts when an expected run has not succeeded within its window, for example flagging a nightly job that has not completed in 25 hours. A job that silently stops produces no error to catch, so the only way to notice is to watch for the missing success and alert on it, alongside normal failure and backlog alerts.
Do I need a queue, or is cron enough?
Cron alone is fine when the scheduled work is small and finishes well within the function limit. Add a queue once the work is long, needs reliable retries, or must fan out into many pieces, because the queue gives you short units, automatic redelivery on failure, and a dead-letter record. A common, robust setup is cron to trigger on schedule plus a queue to actually do the heavy lifting.
If your app needs reports, reminders, syncs, or billing that run on their own, tell me what has to happen and when and I will map out a background-job setup on Vercel that holds up under load.
Want a hand applying this?
Tell me where your business is stuck and I will give you a straight, useful read, no pitch.