All posts

Why cron jobs don't work in serverless (and what to use instead)

If you've moved an app to Vercel, Netlify, Cloudflare Workers, or AWS Lambda and tried to run a scheduled task, you've probably hit a wall. The cron job that worked fine on your old VPS either doesn't exist, only runs on a fixed schedule, or quietly never fires. This isn't a bug in your setup — it's a fundamental mismatch between how cron works and how serverless works.

Here's why classic cron doesn't translate to serverless, what the platform "cron" features actually do, and how to pick the right tool for the three different things people usually mean by "a scheduled job."

What cron actually assumes

cron is a daemon. It has been running on Unix machines since the 1970s, and its whole design rests on two assumptions:

  1. There is a long-lived process. The cron daemon starts at boot and stays resident in memory, indefinitely.
  2. That process owns a clock. Every minute it wakes up, compares the current time against its table of schedules, and runs whatever is due.

A crontab entry like 0 9 * * * works because something is always awake to notice that it just became 9:00. Cron is a loop that never exits.

Why serverless breaks both assumptions

Serverless platforms are built on the opposite premise. Your code is not a resident process — it's a function that the platform spins up on demand to handle a request and then tears down.

  • No persistent process. When your function returns its response, the runtime is frozen or destroyed. There is no daemon sitting in memory waiting for 9:00 to roll around.
  • Scale to zero. With no incoming traffic, zero instances of your code are running. There is literally nothing alive to check a clock.
  • Cold starts and ephemerality. Even while serving traffic, instances come and go. Any timer, interval, or in-memory state you set up dies with the instance — often within seconds.

This is why the most common first attempt fails:

// ❌ This does NOT work on serverless.
export async function POST(req: Request) {
  // schedule a follow-up in 3 days
  setTimeout(() => {
    fetch("https://api.myapp.com/do-the-thing");
  }, 3 * 24 * 60 * 60 * 1000);

  return Response.json({ ok: true });
}

The function returns its response immediately, the runtime is frozen, and that setTimeout callback never runs. There is no process left alive to run it. Even on a platform that keeps the instance warm for a few minutes, a three-day timer has no chance.

What "serverless cron" features really are

Most platforms offer something called cron — Vercel Cron Jobs, Cloudflare Cron Triggers, EventBridge Scheduler rules, GitHub Actions schedule. These are real and useful, but it's important to understand exactly what they give you: the platform runs a persistent scheduler for you, and pings one of your endpoints on a recurring, fixed schedule.

That solves assumption #1 (something is always awake) by moving it off your serverless code and into the platform. But notice the shape of what you get back:

  • It's recurring and fixed0 9 * * *, every day at 9. You define the schedule ahead of time, in config or a dashboard.
  • It has a minimum granularity (often one minute) and a cap on how many schedules you can define.
  • It is not a way to say "call this specific URL once, at this specific future timestamp, with this payload."

So platform cron is great for: nightly cleanups, hourly cache warms, a daily digest email, polling something every 15 minutes. It is the wrong tool the moment the time or the number of jobs is dynamic.

The three things people mean by "scheduled job"

Almost every "how do I run cron on serverless" question is actually one of these three, and they need different tools:

1. Recurring work on a fixed schedule

"Send a weekly summary every Monday at 8am." The schedule is known in advance and the same forever.

Use platform cron. Vercel Cron / Cloudflare Triggers / EventBridge are purpose-built for this. Point them at an endpoint and you're done.

2. One-off work at an arbitrary future time

"When this user starts a trial, call my endpoint exactly 3 days before it ends." The fire time is computed at runtime and is different for every user. You can't write it into a crontab — you don't know it until the event happens, and there could be thousands of them.

You need a scheduler or a delayed queue that accepts a single job with a specific timestamp and a payload. This is what platform cron can't do.

3. Per-user dynamic scheduling

"Each customer picks when their report runs, and can change it anytime." This is case #2 at scale, plus mutation — schedules get created, updated, and canceled constantly.

You need external durable state (a database and/or a scheduler service). There is nowhere in a stateless function to keep "everyone's chosen times."

The patterns that actually work

Once something else owns the clock, you have a few options for cases #2 and #3:

  • Database + a poller. Store { run_at, payload } rows, and run a recurring platform-cron job every minute that queries for due rows and processes them. This works, but it fights precision: your job can only fire as often as your poller runs, so a one-minute poller means up to ~60s of slack, and a one-second poller means hammering your database 86,400 times a day.
  • A delayed queue. Hand the job to a message queue that supports per-message delay (and dedup, retries, backoff). Better precision and durability, but now you're running and paying for queue infrastructure, and for "call this URL later" it's a lot of machinery.
  • A dedicated webhook scheduler. Hand the future timestamp, URL, and payload to a service whose entire job is to fire that one HTTP call at the right second — signed, retried, and observable — with nothing for you to host.

Where SendItWhenever fits

SendItWhenever is the third option, focused narrowly on cases #2 and #3. You give it a URL, a fire time, and a payload from inside your normal request handler:

import { SendIt } from "@sendithq/sdk";

const sendit = new SendIt(process.env.SENDIT_API_KEY!);

await sendit.schedule({
  url: "https://api.myapp.com/trial-ending",
  fireAt: "2026-07-01T09:00:00Z", // any future second, computed at runtime
  payload: { userId },
});

At that second it POSTs your payload with an HMAC signature, retries on failure with exponential backoff, and lands dead jobs in a Dead Letter Queue you can inspect. The payload is encrypted at rest. There's no daemon to keep alive, no poller to tune, and no minute-granularity ceiling — firing is second-level precise with Early Firing to offset round-trip latency.

Cron didn't break — it just assumes a machine that serverless deliberately doesn't give you. For recurring jobs, lean on your platform's cron triggers. For one-off and per-user scheduling, let something else own the clock.

Get started