Backend / ReliabilityConcept

Cron jobs that survive a redeploy.

Tasks live in Postgres with idempotency keys. pg_cron tries first, Vercel Cron is a backstop. Every run is logged. Every failure retries with backoff.

Vercel Cron is convenient, but the moment your job needs to know whether it already ran today, you have to bring state somewhere. This pattern keeps every scheduled task and its run history in Postgres. The runner is dumb: pick the next due job, run it, mark it done. Idempotency is enforced by the database, not your function.

Get a walkthrough
01 / 03

What it does

Two tables in Postgres: scheduled_tasks and task_runs. A row in scheduled_tasks defines a kind, a cron expression, and a payload. The runner reads tasks whose next_run_at is in the past, claims them with a SELECT FOR UPDATE SKIP LOCKED, executes the matching handler, writes the outcome to task_runs, and updates next_run_at.

The trigger is doubled. pg_cron fires every minute and notifies a worker via LISTEN/NOTIFY. Vercel Cron also pings an HTTP endpoint every minute as a backstop, in case the database notify is missed during a deploy. Both routes go through the same claim function, so duplicate fires are cheap, not destructive.

Every run is idempotent on (task_id, scheduled_for). Re-running the same minute is a no-op. Re-running a missed window is explicit and audited.

02 / 03

The problem it solves

I have lost count of the number of times a Vercel deploy has caused a serverless cron to fire twice in a minute, or skip a minute entirely. For a marketing email blast, twice is a customer support incident. For a billing job, twice is a refund.

This pattern absorbs both failure modes. The job is defined once, in Postgres. Whoever calls the runner first wins the row. Everyone else either gets nothing or marks themselves a no-op. You can deploy a hundred times an hour and the schedule stays honest.

03 / 03

Architecture

The whole pattern is two tables, one Postgres function, and one Next.js route handler. No queue server, no Redis, no Temporal. Postgres is doing all the hard work because it is good at it.

  • scheduled_tasks: id, kind, cron, payload, next_run_at, last_run_at.
  • task_runs: id, task_id, scheduled_for, started_at, finished_at, status, error.
  • claim_due_task() function with SELECT FOR UPDATE SKIP LOCKED.
  • pg_cron job every minute calling claim_due_task and pg_notify.
  • Vercel Cron hitting /api/cron/run every minute as a backstop.
  • Handler registry in code keyed by kind. New job type, new entry, that is it.
Code

The interesting bits.

sql·migrations/001_tasks.sql
create table scheduled_tasks (
  id           uuid primary key default gen_random_uuid(),
  kind         text not null,
  cron         text not null,
  payload      jsonb not null default '{}'::jsonb,
  next_run_at  timestamptz not null,
  last_run_at  timestamptz,
  created_at   timestamptz not null default now()
);

create table task_runs (
  id            bigserial primary key,
  task_id       uuid not null references scheduled_tasks(id) on delete cascade,
  scheduled_for timestamptz not null,
  started_at    timestamptz not null default now(),
  finished_at   timestamptz,
  status        text not null check (status in ('running','ok','error')),
  error         text,
  unique (task_id, scheduled_for)
);

create or replace function claim_due_task()
returns scheduled_tasks
language plpgsql
as $$
declare
  t scheduled_tasks;
begin
  select * into t
  from scheduled_tasks
  where next_run_at <= now()
  order by next_run_at
  for update skip locked
  limit 1;

  if not found then return null; end if;

  insert into task_runs (task_id, scheduled_for, status)
  values (t.id, t.next_run_at, 'running')
  on conflict (task_id, scheduled_for) do nothing;

  return t;
end;
$$;
typescript·app/api/cron/run/route.ts
import { NextResponse } from 'next/server'
import { sql } from '@/lib/db'
import { handlers } from '@/lib/tasks/handlers'
import { nextOccurrence } from '@/lib/tasks/cron'

export const dynamic = 'force-dynamic'

export async function GET(req: Request) {
  if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
    return new NextResponse('forbidden', { status: 403 })
  }

  for (let i = 0; i < 10; i++) {
    const [task] = await sql`select * from claim_due_task()`
    if (!task) break

    const handler = handlers[task.kind]
    if (!handler) {
      await sql`
        update task_runs set status='error', finished_at=now(), error=${'unknown kind'}
        where task_id=${task.id} and scheduled_for=${task.next_run_at}
      `
      continue
    }

    try {
      await handler(task.payload)
      await sql`
        update task_runs set status='ok', finished_at=now()
        where task_id=${task.id} and scheduled_for=${task.next_run_at}
      `
    } catch (e: any) {
      await sql`
        update task_runs set status='error', finished_at=now(), error=${e.message}
        where task_id=${task.id} and scheduled_for=${task.next_run_at}
      `
    }

    const next = nextOccurrence(task.cron, new Date(task.next_run_at))
    await sql`
      update scheduled_tasks
      set last_run_at = next_run_at, next_run_at = ${next}
      where id = ${task.id}
    `
  }

  return NextResponse.json({ ok: true })
}
Tech stack

Tools, picked deliberately.

Vercel CronSupabasePostgres 16pg_cronNext.js Route HandlersPostgres.jsTypeScript
Run it yourself

From clone to working.

01

Run the migration

Apply the SQL above in your Supabase project. pg_cron and pgcrypto need to be enabled in the Database extensions panel.

02

Enable the pg_cron tick

Run: select cron.schedule('cron-tick', '* * * * *', $$select claim_due_task()$$). This is your primary trigger.

03

Add the Vercel cron

In vercel.json, add { "crons": [{ "path": "/api/cron/run", "schedule": "* * * * *" }] }. Set CRON_SECRET in env. This is your backstop.

04

Register a handler

Add a key to handlers, with an async function that takes the payload and does the work. Keep it idempotent at the business level too.

05

Insert a task

insert into scheduled_tasks (kind, cron, payload, next_run_at) values ('send_digest', '0 9 * * 1', '{}', '2026-05-04 09:00+00').

06

Watch it run

Tail the task_runs table. You should see one row per scheduled minute, status ok, started_at and finished_at within a few seconds of each other.

Want early access?

This one is in the workshop. The pattern is documented above; the open source release is planned. Email me for a walkthrough or early access.

Email me