4.8 · Background Work and Async Jobs

4.8 · 25 min

Background Work and Async Jobs

A user uploads a 40MB deliverable. Your handler generates a thumbnail, extracts a text preview, and emails the client that a new file is ready, then returns. It works locally. In production the upload occasionally 504s, because the handler is doing three slow things while the user's browser waits, and the email provider had a bad two seconds. The work was real; the mistake was doing it inside the request. Background jobs exist to move work that outlives a request out of the request, so the response returns fast and the slow work runs where a timeout does not cost the user their upload.

When it is actually a job

Not all slow work needs a queue. The decision has three questions:

Does the user need the result to continue? If yes (a search query, a permission check), it is a request, make it fast. If no (send an email, generate a thumbnail, rebuild a digest), it can be a job. Briefcase's daily digest and its file post-processing are both "no": nobody is blocked waiting.

Can it exceed the function's time budget, or fail transiently? Vercel functions have a bounded duration (as of July 2026, 300s default on all plans, with higher maximums on Pro/Enterprise), and anything network-bound (email, image processing via a service) can fail for reasons that succeed on retry. Work that is both slow and flaky wants a job with retries, not a request that either blocks or dies.

Must it survive a crash? If the fact that the work is owed must not be lost when a function dies mid-run, it needs durable state, a row somewhere that says "this is pending". A fire-and-forget setTimeout after the response loses the work on any restart. Briefcase's "email the client on approval" must survive; a best-effort log line does not.

If the answer is request, keep it a request; a queue for work that fits in 200ms is complexity with no payoff. Briefcase has exactly two real jobs: the notification digest (scheduled) and file processing (triggered by upload).

The position: a jobs table, not a queue vendor

Here is a defended position, because the WRITING gate for this lesson demands one and a survey teaches nothing. For a solo-operator SaaS with a handful of job types, build a jobs table in your Postgres and work it with a cron-swept endpoint. Do not add a queue vendor yet. The reasoning, and the honest case against:

The vendor options are real and good. Inngest and Trigger.dev both give you durable functions with retries, scheduling, and a dashboard, writing jobs as plain async code with step.run boundaries; Inngest's Next.js quick start is a ten-minute setup with a local dev server. Vercel Queues is a first-party durable log with retries and idempotency keys, and Vercel Workflows layers 'use workflow'/'use step' durable execution on top. These are the right answer at the scale where you have many job types, long multi-step chains, fan-out, or complex scheduling. That scale is real and you will get there if the product grows.

The case for the table at your current scale is that every vendor is a second place your system's state lives, a second dashboard, a second failure mode, and a second thing to reason about during an incident, in exchange for solving problems (fan-out, long chains) that two job types do not have. Your jobs table lives in the database you already operate, is queryable with the same SQL as everything else, is testable in the same suite (4.5's harness), and adds zero new vendors. As of July 2026, Vercel Queues is still in Beta, which is its own reason not to put billing-adjacent work on it yet. The table's honest ceiling: high job frequency, heavy fan-out, or long dependent chains will outgrow it, and the migration to a vendor at that point is real work. You accept that ceiling knowingly (it is ADR-003), and you revisit when the numbers cross it, not before. This is 3.10's rigor dial: match the infrastructure to the actual load, not to the architecture blog.

The jobs table and its worker

The schema, created by this lesson's migration alongside the claim function, carries everything a minimal durable queue needs:

jobs table shape (migration 0004)sql
-- jobs: id, kind (notification_digest | file_processing), payload,
--   idempotency_key (unique), status (pending | running | succeeded |
--   failed | dead), attempts, max_attempts, run_after, last_error,
--   created_at, updated_at   [service-role only, RLS denies clients]

Producers enqueue by inserting a row with an idempotency_key; the unique constraint makes a double-enqueue a no-op. The worker is a route handler that claims a batch, runs each within a time budget below the function limit, and records the outcome:

briefcase/app/api/jobs/run/route.tsts
import { createServiceClient } from '@/lib/supabase/service'
import { handlers } from '@/lib/jobs/handlers'

export const maxDuration = 60 // seconds; our budget sits under this

export async function POST(req: Request) {
// Only the cron (or an internal kick) may run the worker.
const auth = req.headers.get('authorization')
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
  return new Response('Unauthorized', { status: 401 })
}

const db = createServiceClient()
const deadline = Date.now() + 50_000 // leave headroom under maxDuration

while (Date.now() < deadline) {
  // Claim one due job atomically: flip pending -> running and stamp
  // the claim time, so a second worker cannot grab the same row.
  const { data: job } = await db.rpc('claim_next_job').single()
  if (!job) break // nothing due

  try {
    await handlers[job.kind](job.payload) // the actual work
    await db.from('jobs').update({
      status: 'succeeded', updated_at: new Date().toISOString(),
    }).eq('id', job.id)
  } catch (err) {
    const attempts = job.attempts + 1
    const dead = attempts >= job.max_attempts
    await db.from('jobs').update({
      status: dead ? 'dead' : 'pending',
      attempts,
      last_error: String(err),
      // exponential backoff before the next attempt
      run_after: new Date(
        Date.now() + Math.min(2 ** attempts, 60) * 60_000,
      ).toISOString(),
      updated_at: new Date().toISOString(),
    }).eq('id', job.id)
  }
}
return Response.json({ ok: true })
}

The claim_next_job RPC is where correctness lives, and it uses Postgres's own locking so two concurrent workers never claim the same row. It ships in the same migration that creates the jobs table:

supabase/migrations/0004_jobs_and_claim.sql (excerpt)sql
create function claim_next_job()
returns jobs
language sql
as $$
update jobs
set status = 'running', updated_at = now()
where id = (
  select id from jobs
  where status = 'pending' and run_after <= now()
  order by run_after
  for update skip locked   -- concurrent workers skip claimed rows
  limit 1
)
returning *;
$$;

for update skip locked is the classic Postgres-as-a-queue primitive: each worker locks and takes a different row, and skip locked means a busy row is passed over rather than blocked on. Three behaviors complete the design. Retries: a failed job goes back to pending with a backed-off run_after, until max_attempts, then dead (the dead-letter state, where a human looks). Idempotency: producers dedupe on idempotency_key, and each handler is itself safe to run twice (the digest recomputes from current data; the email handler checks a sent-marker). Stale-claim recovery: a separate sweep re-queues rows stuck in running past a timeout, catching workers that died mid-job.

Triggering: schedule and direct kick

Two producers, two triggers. The digest is scheduled via Vercel Cron, which makes a GET to a path on a schedule; the endpoint is secured with CRON_SECRET (Vercel sends it as a Bearer token, the handler checks it, exactly as the worker above does):

briefcase/vercel.jsonjson
{
"crons": [
  { "path": "/api/cron/enqueue-digests", "schedule": "0 13 * * *" },
  { "path": "/api/jobs/run",             "schedule": "*/5 * * * *" }
]
}

The first cron enqueues a digest job per user at 13:00 UTC; the second is the worker sweep every five minutes, which drains whatever is pending and re-queues stale running rows. File processing is latency-sensitive, so its producer (the upload handler) both enqueues the job and directly kicks the worker endpoint after responding, so processing starts in seconds rather than waiting up to five minutes for the next sweep. The cron sweep remains the safety net that catches any kick that failed to land. This two-path design (durable enqueue plus optional immediate kick) came directly out of 4.2's sparring session.

The lifecycle test as the gate

The verification is a scripted job lifecycle test that walks a job through every state, including failure and the dead-letter terminus:

briefcase/tests/jobs/lifecycle.test.tsts
import { test, expect } from '@playwright/test'
import { enqueue, runWorker, jobRow, withFailingHandler } from './helpers'

test('job lifecycle: enqueue, run, succeed', async () => {
const id = await enqueue('notification_digest', { userId: 'u1' })
await runWorker()
expect((await jobRow(id)).status).toBe('succeeded')
})

test('a failing job retries then dead-letters', async () => {
await withFailingHandler('file_processing', async () => {
  const id = await enqueue('file_processing',
    { deliverableId: 'd1' }, { maxAttempts: 3 })
  await runWorker(); expect((await jobRow(id)).attempts).toBe(1)
  await runWorker(); await runWorker() // exhaust attempts
  const row = await jobRow(id)
  expect(row.attempts).toBe(3)
  expect(row.status).toBe('dead')     // dead-letter, not silent loss
  expect(row.last_error).toBeTruthy()
})
})

test('duplicate enqueue with same idempotency_key is a no-op', async () => {
const key = 'digest:u1:2026-07-05'
await enqueue('notification_digest', { userId: 'u1' }, { key })
await enqueue('notification_digest', { userId: 'u1' }, { key })
// one row, not two: the unique constraint held
expect(await countJobs(key)).toBe(1)
})

test('a job stuck in running past the timeout is re-queued', async () => {
const id = await enqueue('file_processing', { deliverableId: 'd2' })
await forceStatus(id, 'running', { claimedMinutesAgo: 30 })
await sweepStale()
expect((await jobRow(id)).status).toBe('pending') // recovered
})

The four cases are the four things that actually go wrong: a job that runs, a job that fails and must dead-letter instead of vanishing or looping forever, a duplicate that must not double-process, and a worker death that must not strand work. A test that only enqueues and succeeds certifies the path that never needed testing. This suite is Checkpoint 4's background-job gate.

The dispatch

PromptReference dispatch: background jobs (lab 4-8)

DISPATCH: briefcase background jobs (digest + file processing)

GOAL: a durable jobs table with a cron-swept worker runs the notification digest and file-processing jobs; jobs retry with backoff, dead-letter after max_attempts, dedupe on idempotency_key, and recover from stale claims; a scripted lifecycle test proves all four behaviors.

IN SCOPE: supabase/migrations/0004_jobs_and_claim.sql (the jobs table exactly as specified above, plus claim_next_job); the worker route app/api/jobs/run; app/api/cron/enqueue-digests; lib/jobs/handlers.ts (notification_digest, file_processing); lib/email.ts (sendEmail abstraction; provider chosen after checking current options, wired behind this one function); vercel.json crons; the upload handler's direct-kick; the stale sweep; tests/jobs/lifecycle.test.ts + helpers; .env.example (CRON_SECRET, email provider keys); STATUS.md.

OUT OF SCOPE: adding a queue vendor (ADR-003: table for now); the RAG feature (elective A8); real bulk email sending in tests (assert the sent-marker, do not hit a provider); new job kinds beyond the two named.

DON'T TOUCH: existing tables from 4.5's schema; the jobs column set above is the contract (change it only by raising it first); the env hook; billing code; docs/*.

VERIFICATION:

  1. npm run typecheck && npm run lint && npm run build green.
  2. claim_next_job uses FOR UPDATE SKIP LOCKED; the worker is auth'd by CRON_SECRET; the worker's time budget sits under maxDuration. I will read all three.
  3. Lifecycle test green AND includes: retry-then-dead-letter, idempotent duplicate enqueue, and stale-claim recovery, not just enqueue-and-succeed.
  4. Operator: enqueue a failing job locally, run the worker three times, confirm it lands in 'dead' with last_error set, and does NOT loop forever or disappear.

Read STATUS.md, TECH-SPEC (jobs columns/enums), and ADR-003 first. Research current email providers, pick one, justify it in one line in STATUS decisions; keep it behind lib/email.ts.

Where it breaks

The first failure is the queue that silently loses work: a handler that throws, gets caught, logs, and returns success, so the job is marked done and the work never happened. The dead-letter state is the fix, and the lifecycle test's retry-then-dead case is what proves failures are visible rather than swallowed. A job system with no dead-letter state is a job system that loses work quietly.

The second is the missing idempotency, on both ends. Without a unique idempotency_key, a double-enqueue (a retried request, a redelivered webhook that enqueues) makes two digests; without a handler that is safe to run twice, a re-run after a mid-job crash double-sends an email. The lesson's design dedupes at enqueue and makes handlers self-idempotent, and the test asserts both.

The third is the worker that claims work non-atomically, so two concurrent invocations grab the same row and do it twice. for update skip locked is the one-line fix, and it is why the claim is an RPC doing an update ... where id = (select ... for update skip locked) rather than a read-then-write in application code, which has a race between the read and the write.

Lablab-4-8Capstone dispatch: durable jobs with a lifecycle gate

Goal: Build the jobs table, worker, and triggers, and gate on a lifecycle test covering retry/dead-letter, idempotency, and stale-claim recovery.

Prereqs: the repo through 4.7, Supabase local stack, a transactional email provider account in test/sandbox mode (pick a current one during the dispatch).

  1. Save the reference dispatch as dispatches/2026-07-jobs.md. Before running it, reproduce the failure the design prevents: on a scratch branch, write a naive worker that reads a pending job with a normal select and then updates it, run two copies concurrently against one job, and observe the double-processing. Delete the branch. That race is what for update skip locked closes.
  2. Run the dispatch in a fresh session. At plan review, confirm the worker claims via the RPC (not read-then-write) and that handlers are written to be safe to run twice.
  3. Run VERIFICATION. Read claim_next_job for for update skip locked, the worker for the CRON_SECRET check and the sub-maxDuration budget. Then enqueue a deliberately failing job, run the worker to exhaustion, and confirm it reaches dead with last_error set, not an infinite loop and not a silent success.
  4. Run the lifecycle suite; confirm all four cases green, especially retry-then-dead-letter, duplicate-enqueue no-op, and stale-claim recovery.
  5. Close the loop: STATUS.md (including the one-line email-provider decision), CI green, commit, /clear.

Verify

  • The naive-worker branch demonstrably double-processed one job; the RPC-based claim does not (you observed both).
  • claim_next_job uses for update skip locked; the worker checks CRON_SECRET and its loop deadline is below maxDuration.
  • The lifecycle test is green and includes retry-to-dead-letter (with last_error), idempotent duplicate enqueue (one row), and stale-claim recovery (running -> pending after sweep).
  • A locally enqueued failing job lands in dead after max_attempts, does not loop forever, and does not silently succeed.
>Troubleshooting
  • Two workers still double-process: your claim is a select followed by a separate update in app code, which has a race window. Move the claim into a single SQL statement with for update skip locked, as the RPC does; the atomicity has to be in the database, not the handler.
  • Failed jobs loop forever: your failure branch resets to pending but never increments attempts or checks max_attempts, so nothing ever dead-letters. Increment on every failure and transition to dead at the cap.
  • The digest sends twice some days: either the enqueue lacks a per-user-per-day idempotency_key, or the handler is not self-idempotent (no sent-marker check). Fix both; the unique key stops double-enqueue, the marker stops double-send on a mid-job retry.

Knowledge check

Knowledge check

Q1For Briefcase's two job types on a solo-operator budget, this lesson argues for a Postgres jobs table over Inngest, Trigger.dev, or Vercel Queues. Which statement best captures the actual argument (not a strawman)?
Q2The worker claims a job with UPDATE jobs SET status='running' WHERE id = (SELECT id ... FOR UPDATE SKIP LOCKED LIMIT 1). Why is the FOR UPDATE SKIP LOCKED essential?
Q3A job handler throws, the worker catches it, logs the error, and marks the job succeeded so it stops retrying a broken job. What is wrong, and what is the correct terminal behavior?
Q4Briefcase enqueues one digest job per user per day at 13:00 UTC. A retry of the enqueue-cron (or an overlap) fires it twice. What single mechanism prevents two digest emails, and where does it live?

Sources