Leer het cron + database-patroon om geplande achtergrondtaken uit te voeren met retries, locking en idempotentie — zonder een volledig queuesysteem op te zetten.

De meeste apps moeten werk op een later moment uitvoeren of op schema: opvolg-e-mails sturen, een nachtelijke factureringscontrole draaien, oude records opruimen, een rapport herbouwen of een cache verversen.
In het begin is het verleidelijk om meteen een volledig queuesysteem toe te voegen omdat het voelt als de “juiste” manier om achtergrondtaken te doen. Maar queues brengen extra onderdelen: een extra service om te draaien, te monitoren, te deployen en te debuggen. Voor een klein team (of een solo-founder) kan die extra last je juist vertragen.
De echte vraag is dus: hoe voer je geplande taken betrouwbaar uit zonder extra infrastructuur op te zetten?
Een veelvoorkomende eerste poging is simpel: voeg een cron-entry toe die een endpoint aanroept, en laat dat endpoint het werk doen. Dat werkt totdat het niet meer werkt. Zodra je meer dan één server hebt, een deploy op een ongelukkig moment, of een taak die langer duurt dan verwacht, begin je verwarrende fouten te zien.
Geplande taken gaan meestal op een paar voorspelbare manieren stuk:
Het cron + database-patroon is een middenweg. Je gebruikt nog steeds cron om op schema “wakker te worden”, maar je slaat intentie en staat van taken op in je database zodat het systeem kan coördineren, opnieuw proberen en registreren wat er gebeurde.
Het past goed wanneer je al één database hebt (vaak PostgreSQL), een klein aantal taaktypes, en je voorspelbaar gedrag wilt met minimale ops-lasten. Het is ook een natuurlijke keuze voor apps die snel gebouwd zijn met moderne stacks (bijvoorbeeld React + Go + PostgreSQL).
Het is geen goede keuze wanneer je zeer hoge doorvoer nodig hebt, langlopende taken die voortgang moeten streamen, strikte ordering over veel taaktypes, of zware fan-out (duizenden subtaken per minuut). In die gevallen betaalt een echt queue-systeem met dedicated workers zich meestal terug.
Het cron + database-patroon voert achtergrondwerk op schema uit zonder een volledig queuesysteem. Je gebruikt nog steeds cron (of een scheduler), maar cron beslist niet wat er wordt uitgevoerd. Cron wekt alleen een worker regelmatig (bijvoorbeeld iedere minuut). De database bepaalt welk werk aan de beurt is en zorgt dat slechts één worker een taak pakt.
Denk aan het als een gedeelde checklist op een whiteboard. Cron is de persoon die elke minuut de kamer binnenloopt en zegt: “Moet er nu iets gebeuren?” De database is het whiteboard dat laat zien wat er due is, wat al is gepakt en wat klaar is.
De onderdelen zijn eenvoudig:
Voorbeeld: je wilt elke ochtend factuurherinneringen sturen, een cache elke 10 minuten verversen en 's nachts oude sessies opruimen. In plaats van drie aparte cron-commando's (elk met hun eigen overlaps en faalmodes) sla je taakentries op op één plek. Cron start hetzelfde workerproces. De worker vraagt Postgres: "Wat is er nu due?" en Postgres laat de worker veilig precies één taak per keer claimen.
Dit schaalt geleidelijk. Je kunt starten met één worker op één server. Later kun je vijf workers over meerdere servers draaien. De contract blijft hetzelfde: de tabel is het contract.
De denkwijze verandert dus: cron is alleen de wekker. De database is de verkeersregelaar die bepaalt wat mag draaien, vastlegt wat er gebeurde en je een helder historisch overzicht geeft als er iets misgaat.
Dit patroon werkt het beste wanneer je database de bron van waarheid wordt voor wat moet draaien, wanneer het moet draaien en wat er de vorige keer gebeurde. Het schema is niet fancy, maar kleine details (lock-velden en de juiste indexes) schelen veel zodra de load toeneemt.
Twee veelvoorkomende benaderingen:
Als je verwacht vaak te debuggen, houd historie. Als je de kleinst mogelijke setup wilt, begin met één tabel en voeg historie later toe.
Hier is een PostgreSQL-vriendelijke indeling. Als je in Go met PostgreSQL bouwt, mappen deze kolommen netjes naar structs.
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Een paar details die later veel pijn besparen:
send_invoice_emails).jsonb zodat je het kunt evolueren zonder migraties.Zonder indexes scannen workers te veel. Begin met:
(status, run_at)(locked_until)queued en failed)Deze houden de "vind volgende uitvoerbare job"-query snel, ook als de tabel groter wordt.
Het doel is simpel: veel workers kunnen draaien, maar slechts één mag een specifieke job pakken. Als twee workers dezelfde rij verwerken, krijg je dubbele e-mails, dubbele kosten of rommelige data.
Een veilige aanpak is om een job-claim te behandelen als een "lease". De worker markeert de job als vergrendeld voor een korte periode. Als de worker crasht, verloopt de lease en kan een andere worker hem oppakken. Daar is locked_until voor.
Zonder lease kan een worker een job vergrendelen en nooit ontgrendelen (proces killed, server reboot, mislukte deploy). Met locked_until wordt de job weer beschikbaar wanneer de tijd voorbij is.
Een typische regel is: een job kan geclaimd worden wanneer locked_until NULL is of locked_until \u003c= now().
De sleutel is om de job in één statement (of in één transactie) te claimen. Je wilt dat de database de scheidsrechter is.
Hier is een veelgebruikt PostgreSQL-patroon: pak één due job, lock hem en geef hem terug aan de worker. (Dit voorbeeld gebruikt een enkele jobs-tabel; dezelfde gedachte geldt als je van job_runs claimt.)
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at \u003c= now()
AND (locked_until IS NULL OR locked_until \u003c= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
Waarom dit werkt:
FOR UPDATE SKIP LOCKED laat meerdere workers concurreren zonder elkaar te blokkeren.RETURNING geeft de rij aan de worker die de race won.Stel de lease langer in dan een normale uitvoering, maar kort genoeg zodat een crash snel herstelt. Als de meeste taken in 10 seconden klaar zijn, is een lease van 2 minuten ruim voldoende.
Voor lange taken verleng je de lease terwijl je werkt (een heartbeat). Een eenvoudige aanpak: verleng locked_until elke 30 seconden als je de job nog bezit.
WHERE id = $job_id AND locked_by = $worker_id bevattenDie laatste voorwaarde is belangrijk. Het voorkomt dat een worker een lease verlengt op een job die hij niet meer bezit.
Retries zijn het moment waarop dit patroon rustig voelt of verandert in een rumoerige puinhoop. Het doel is simpel: wanneer een job faalt, probeer het later opnieuw op een manier die je kunt uitleggen, meten en stoppen.
Begin met expliciete en eindige job-staten: queued, running, succeeded, failed, dead. In de praktijk gebruiken teams failed voor "gefaald maar wordt opnieuw geprobeerd" en dead voor "gefaald en we geven op". Dat ene onderscheid voorkomt oneindige loops.
Tellen van pogingen is de tweede vangrail. Sla attempts (hoe vaak je het geprobeerd hebt) en max_attempts (hoe vaak je het toelaat) op. Wanneer een worker een error vangt, zou die moeten:
attempts verhogenfailed zetten als attempts \u003c max_attempts, anders deadrun_at berekenen voor de volgende poging (alleen voor failed)Backoff is gewoon de regel die bepaalt wat de volgende run_at wordt. Kies er één, documenteer hem en houd hem consistent:
Jitter is van belang wanneer een afhankelijkheid uitvalt en terugkomt. Zonder jitter zullen honderden jobs tegelijk opnieuw proberen en weer falen.
Sla genoeg foutdetails op om fouten zichtbaar en te debuggen te maken. Je hoeft geen volledig loggingsysteem te hebben, maar wel de basics:
last_error (korte melding, veilig om in een admin-scherm te tonen)error_code of error_type (helpt groeperen)failed_at en next_run_atlast_stack (alleen als je de grootte beheerst)Een concreet werkende regel: markeer jobs na 10 pogingen als dead, en gebruik exponentiële backoff met jitter. Dat laat tijdelijke fouten opnieuw proberen, maar voorkomt dat kapotte jobs de CPU voor altijd verbranden.
Idempotentie betekent dat je job twee keer kan draaien en toch hetzelfde eindresultaat oplevert. In dit patroon is dat belangrijk omdat dezelfde rij opnieuw gepakt kan worden na een crash, timeout of retry. Als je job "stuur een factuur-e-mail", dan is het niet onschuldig om die twee keer uit te voeren.
Denk er praktisch over na: splits elke job in (1) het uitvoeren van werk en (2) het toepassen van een effect. Je wilt dat het effect één keer gebeurt, zelfs als het werk meerdere keren geprobeerd wordt.
Een idempotency-sleutel moet voortkomen uit wat de job vertegenwoordigt, niet uit de worker-attempt. Goede sleutels zijn stabiel en makkelijk uit te leggen, zoals invoice_id, user_id + day, of report_name + report_date. Als twee jobpogingen naar hetzelfde echte wereld-event verwijzen, moeten ze dezelfde sleutel delen.
Voorbeeld: "Genereer dagelijkse verkooprapport voor 2026-01-14" kan sales_report:2026-01-14 gebruiken. "Incasseer factuur 812" kan invoice_charge:812 gebruiken.
De eenvoudigste vangrail is PostgreSQL duplicates laten weigeren. Sla de idempotency-sleutel ergens op die geïndexeerd kan worden en voeg een unieke constraint toe.
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
Dit voorkomt dat er twee rijen met dezelfde sleutel tegelijkertijd bestaan. Als je ontwerp meerdere rijen toestaat (voor historie), zet de uniekheid dan op een "effects"-tabel in plaats daarvan, zoals sent_emails(idempotency_key) of payments(idempotency_key).
Veelvoorkomende bijwerkingen om te beschermen:
sent_emails-rij met een unieke sleutel voordat je verzendt, of registreer een provider message id zodra verzonden.delivered_webhooks(event_id) op en sla over als die al bestaat.file_generated-record op die gekeyed is op (type, date).Als je op een Postgres-backed stack bouwt (bijvoorbeeld Go + PostgreSQL), zijn deze uniekheidschecks snel en makkelijk dicht bij de data te houden. De kerngedachte is simpel: retries zijn normaal, duplicaten zijn optioneel.
Kies één saaie runtime en houd je daaraan. Het punt van het cron + database-patroon is minder bewegende delen, dus een klein Go-, Node- of Python-proces dat met PostgreSQL praat is meestal genoeg.
Maak de tabellen en indexen aan. Voeg een jobs-tabel toe (plus eventuele lookup-tabellen later), indexeer run_at, en voeg een index toe die je worker helpt snel beschikbare jobs te vinden (bijvoorbeeld op (status, run_at)).
Schrijf een kleine enqueue-functie. Je app moet een rij invoegen met run_at ingesteld op "nu" of een toekomstige tijd. Houd de payload klein en voorspelbaar (ID's en een job-type, geen enorme blobs).
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
running in dezelfde transactie.WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at \u003c= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
Verwerk en finaliseer. Voor elke geclaimde job: doe het werk, update daarna naar done met finished_at. Als het faalt, registreer een fout en zet het terug naar queued met een nieuwe run_at (backoff). Houd finalisatie-updates klein en voer ze altijd uit, zelfs als je proces afsluit.
Voeg retryregels toe die je kunt uitleggen. Gebruik een eenvoudige formule zoals run_at = now() + (attempts^2) * interval '10 seconds', en stop na max_attempts door status = 'dead' te zetten.
Je hebt geen volledig dashboard op dag één nodig, maar wel genoeg om problemen te merken.
Als je al op een Go + PostgreSQL stack zit, mapt dit netjes naar één worker-binary plus cron.
Stel je een kleine SaaS-app voor met twee stukken gepland werk:
Houd het simpel: één PostgreSQL-tabel om jobs in te bewaren, en één worker die elke minuut draait (door cron getriggerd). De worker claimt due jobs, draait ze en registreert succes of falen.
Je kunt jobs vanaf een paar plekken enqueue'en:
cleanup_nightly job voor "vandaag".send_weekly_report job voor de volgende maandag van de gebruiker.send_weekly_report job die direct draait voor een specifieke datumbereik.De payload is alleen het minimum dat de worker nodig heeft. Houd het klein zodat het makkelijk opnieuw geprobeerd kan worden.
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
Een worker kan op het slechtste moment crashen: net nadat hij de e-mail heeft verzonden, maar voordat hij de job als "klaar" markeert. Bij herstart kan hij dezelfde job weer oppakken.
Om dubbele verzending te voorkomen, geef het werk een natuurlijke dedupe-sleutel en sla die op een plek waar de database hem kan afdwingen. Voor wekelijkse rapporten is een goede sleutel (user_id, week_start_date). Voordat je verzendt, noteert de worker "Ik ga rapport X verzenden". Als die record al bestaat, slaat hij over.
Dat kan zo simpel zijn als een sent_reports-tabel met een unieke constraint op (user_id, week_start_date), of een unieke idempotency_key op de job zelf.
Stel dat je e-mailprovider timeouts geeft. De job faalt, dus de worker:
attemptsAls het blijft falen voorbij je limiet (bijvoorbeeld 10 pogingen), markeer je het als "dead" en stop je met retries. De job slaagt óf hij probeert opnieuw volgens een duidelijk schema, en idempotentie maakt retries veilig.
Het cron + database-patroon is simpel, maar kleine fouten kunnen het omzetten in duplicaten, vastzittend werk of onverwachte load. De meeste problemen verschijnen na de eerste crash, deploy of traffic spike.
De meeste incidenten komen door een paar valkuilen:
locked_until overslaan. Als een worker crasht na het claimen van een job, kan die rij "in progress" blijven staan. Een lease-timestamp laat een andere worker hem later veilig oppakken.user_id, invoice_id of een file key) en haal de rest tijdens uitvoering op.Voorbeeld: je stuurt een wekelijkse factuur-e-mail. Als de worker timeouts krijgt nadat hij verzonden heeft maar voordat hij de job afhandelt, kan dezelfde job opnieuw proberen en een duplicate e-mail sturen. Dat is normaal voor dit patroon, tenzij je een vangrail toevoegt (bijvoorbeeld: registreer een unieke "email sent"-event op invoice_id).
Vermijd het mixen van scheduling en uitvoering in dezelfde lange transactie. Als je een transactie open houdt tijdens netwerkcalls, houd je locks langer vast dan nodig en blokkeer je andere workers.
Let op klokverschillen tussen machines. Gebruik databasetijd (NOW() in PostgreSQL) als bron van waarheid voor run_at en locked_until, niet de klok van de appserver.
Stel een duidelijke maximale runtime in. Als een job 30 minuten kan duren, maak de lease langer dan dat en verleng hem indien nodig. Anders kan een andere worker hem halverwege oppakken.
Houd je job-tabel gezond. Als voltooide jobs voor altijd blijven liggen, vertragen queries en neemt lock-contest toe. Kies een eenvoudige retentionregel (archiveer of verwijder oude rijen) voordat de tabel te groot wordt.
Voordat je dit patroon uitrolt, controleer de basis. Een kleine weglating hier verandert meestal in vastzittende jobs, verrassende duplicaten of een worker die de database bestormt.
run_at, status, attempts, locked_until en max_attempts (plus last_error of iets soortgelijks zodat je kunt zien wat er gebeurde).invoice_id).max_attempts.Als dit waar is, is het cron + database-patroon meestal stabiel genoeg voor reële workloads.
Zodra de checklist klopt, richt je op dagelijkse operatie.
run_at = now() en wis de lock) en "cancel" (verplaats naar een terminale status). Die schelen tijd tijdens incidenten.status, run_at).Als je dit soort setup snel wilt opzetten, kan Koder.ai je helpen van schema naar een gedeployde Go + PostgreSQL-app met minder handmatig werk, terwijl jij je kunt concentreren op locking, retries en idempotentie-regels.
Als je later deze setup ontgroeit, heb je ondertussen de job-lifecycle helder geleerd en mappen diezelfde ideeën goed naar een volledig queuesysteem.