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: 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:
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:
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):
{
"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:
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
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:
- npm run typecheck && npm run lint && npm run build green.
- 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.
- Lifecycle test green AND includes: retry-then-dead-letter, idempotent duplicate enqueue, and stale-claim recovery, not just enqueue-and-succeed.
- 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).
- 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 whatfor update skip lockedcloses. - 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.
- Run VERIFICATION. Read
claim_next_jobforfor update skip locked, the worker for theCRON_SECRETcheck and the sub-maxDurationbudget. Then enqueue a deliberately failing job, run the worker to exhaustion, and confirm it reachesdeadwithlast_errorset, not an infinite loop and not a silent success. - Run the lifecycle suite; confirm all four cases green, especially retry-then-dead-letter, duplicate-enqueue no-op, and stale-claim recovery.
- 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_jobusesfor update skip locked; the worker checksCRON_SECRETand its loop deadline is belowmaxDuration.- 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
deadaftermax_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
pendingbut never incrementsattemptsor checksmax_attempts, so nothing ever dead-letters. Increment on every failure and transition todeadat 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
Sources
- Vercel Functions maximum duration (300s default across plans, higher maximums on Pro/Enterprise; shapes the worker's sub-budget and the maxDuration export): https://vercel.com/docs/functions/configuring-functions/duration (fetched July 2026)
- Vercel Cron Jobs (GET request to a path on a schedule, vercel.json crons array, cron expression): https://vercel.com/docs/cron-jobs (fetched July 2026)
- Vercel Cron Jobs securing (CRON_SECRET sent as an Authorization Bearer header, Route Handler check): https://vercel.com/docs/cron-jobs/manage-cron-jobs (fetched July 2026)
- Vercel Queues (Beta as of July 2026; idempotency keys, retries, visibility timeouts; the vendor option deferred in ADR-003): https://vercel.com/docs/queues (fetched July 2026)
- Inngest Next.js quick start (createFunction with retries, step.run, serve route handler; the named vendor alternative and when it wins): https://www.inngest.com/docs/getting-started/nextjs-quick-start (fetched July 2026)
- The Briefcase jobs table, worker, claim RPC, and lifecycle test are this course's reference artifacts;
for update skip lockedand the schema are authored against the fetched Vercel docs and standard Postgres-as-a-queue practice.