18 dec 2025·7 min leestijd

Achtergrondtaken plannen zonder cron-kopzorgen: patronen

Leer patronen voor het plannen van achtergrondtaken met workflows en een jobs-tabel om herinneringen, dagoverzichten en opschoning betrouwbaar uit te voeren.

Achtergrondtaken plannen zonder cron-kopzorgen: patronen

Waarom cron eenvoudig lijkt — totdat het dat niet meer is

Cron is geweldig op dag één: schrijf een regel, kies een tijd, vergeet het. Voor één server en één taak werkt het vaak.

De problemen duiken op wanneer je planning cruciaal wordt voor echte productfunctionaliteit: herinneringen, dagoverzichten, opschoning of synchronisatie-taken. De meeste verhalen over “gemiste runs” zijn geen fouten in cron zelf. Het zijn de randvoorwaarden: een serverreboot, een deploy die crontab overschrijft, een taak die langer duurt dan verwacht, of een klok- of tijdzoneverschil. En zodra je meerdere app-instanties hebt, verschijnt het omgekeerde probleem: duplicaten, omdat twee machines denken dat zij dezelfde taak moeten uitvoeren.

Testen is ook zwak. Een cron-regel geeft geen schone manier om te zeggen “wat zou er morgen om 9:00 gebeuren” in een herhaalbare test. Zo wordt planning handmatig controleren, verrassingen in productie en logonderzoek.

Voordat je een aanpak kiest, wees duidelijk over wat je plant. De meeste achtergrondtaken vallen in een paar categorieën:

  • Herinneringen (stuur op een specifiek moment, slechts één keer)
  • Dagoverzichten (aggregeer data en stuur daarna)
  • Opschoningstaken (verwijderen, archiveren, vervallen laten zijn)
  • Periodieke synchronisaties (gegevens ophalen of opsturen)

Soms kun je planning helemaal overslaan. Als iets direct kan gebeuren bij een gebeurtenis (een gebruiker meldt zich aan, een betaling slaagt, een ticket verandert status), is gebeurtenisgestuurde verwerking meestal eenvoudiger en betrouwbaarder dan tijdgestuurde verwerking.

Wanneer je tijd nodig hebt, draait betrouwbaarheid vooral om zichtbaarheid en controle. Je wilt een plek om vast te leggen wat er moet draaien, wat er gedraaid heeft en wat er is misgegaan, plus een veilige manier om opnieuw te proberen zonder duplicaten te maken.

Het basismodel: scheduler, jobs-tabel, worker

Een eenvoudige manier om cron-kopzorgen te vermijden is om verantwoordelijkheden te scheiden:

  • Een scheduler beslist wat wanneer moet draaien.
  • Een worker voert het werk uit.

Het scheiden van die rollen helpt op twee manieren. Je kunt timing veranderen zonder bedrijfslogica aan te raken, en je kunt bedrijfslogica veranderen zonder het schema te breken.

Een jobs-tabel wordt de bron van waarheid. In plaats van staat te verbergen in een serverproces of een cron-regel, is elk werkstuk een rij: wat te doen, voor wie, wanneer het moet draaien en wat er de vorige keer gebeurde. Als iets misgaat, kun je het inspecteren, opnieuw proberen of annuleren zonder te gissen.

Een typisch verloop ziet er zo uit:

  • De scheduler scant naar verschuldigde jobs (bijvoorbeeld run_at <= now en status = queued).
  • Hij claimt een job zodat maar één worker die neemt.
  • Een worker leest de jobdetails en voert de actie uit.
  • De worker registreert het resultaat terug in dezelfde rij.

Het kernidee is werk hervatbaar te maken, niet magisch. Als een worker halverwege crasht, moet de jobrij toch vertellen wat er gebeurde en wat de volgende stap is.

Een jobs-tabel ontwerpen die bruikbaar blijft

Een jobs-tabel moet snel twee vragen kunnen beantwoorden: wat moet als volgende draaien, en wat gebeurde de vorige keer.

Begin met een klein aantal velden die identiteit, timing en voortgang dekken:

  • id, type: een unieke id plus een korte type-aanduiding zoals send_reminder of daily_summary.
  • payload: gevalideerde JSON met alleen wat de worker nodig heeft (bijvoorbeeld user_id, niet het hele gebruikersobject).
  • run_at: wanneer de job in aanmerking komt om te draaien.
  • status: queued, running, succeeded, failed, canceled.
  • attempts: verhoogd bij elke poging.

Voeg daarna een paar operationele kolommen toe die concurrency veilig maken en incidenten makkelijker te behandelen. locked_at, locked_by en locked_until laten één worker een job claimen zodat je niet twee keer draait. last_error moet een korte boodschap zijn (en optioneel een foutcode), niet een volledige stacktrace die rijen opblaast.

Tot slot, houd timestamps die ondersteunen bij support en rapportage: created_at, updated_at en finished_at. Daarmee kun je vragen beantwoorden als “Hoeveel herinneringen zijn vandaag mislukt?” zonder in logs te duiken.

Indexen zijn belangrijk omdat je systeem constant vraagt “wat is de volgende?”. Twee indexen die zich vaak terugbetalen:

  • (status, run_at) om snel verschuldigde jobs te halen
  • (type, status) om een job-familie te inspecteren of te pauzeren tijdens problemen

Voor payloads, geef de voorkeur aan kleine, gefocuste JSON en valideer voordat je invoegt. Sla identifiers en parameters op, geen snapshots van bedrijfsdata. Behandel de payload-structuur als een API-contract zodat oudere in de wachtrij geplaatste jobs blijven werken nadat je de app verandert.

Job lifecycle: statussen, locking en idempotentie

Een job-runner blijft betrouwbaar als elke job een klein, voorspelbaar levenscyclus volgt. Die levenscyclus is je veiligheidsnet wanneer twee workers tegelijk starten, een server herstart middenin een run, of je opnieuw wilt proberen zonder duplicaten te maken.

Een eenvoudige toestandsmachine volstaat meestal:

  • queued: klaar om te draaien op of na run_at
  • running: geclaimd door een worker
  • succeeded: voltooid en mag niet opnieuw draaien
  • failed: beëindigd met een fout en heeft aandacht nodig
  • canceled: intentioneel gestopt (bijv. gebruiker heeft zich afgemeld)

Jobs claimen zonder dubbel werk

Om duplicaten te voorkomen, moet het claimen van een job atomair zijn. De gebruikelijke aanpak is een lock met timeout (een lease): een worker claimt een job door status=running te zetten en locked_by plus locked_until te schrijven. Als de worker crasht, verloopt de lock en kan een andere worker hem opnieuw claimen.

Een praktische set regels om te claimen:

  • claim alleen queued jobs waarvan run_at <= now
  • zet status, locked_by en locked_until in dezelfde update
  • reclaim running jobs alleen wanneer locked_until < now
  • houd de lease kort en verleng deze als de job lang duurt

Idempotentie (de gewoonte die je redt)

Idempotentie betekent: als dezelfde job twee keer draait, blijft het resultaat correct.

Het eenvoudigste hulpmiddel is een unieke sleutel. Bijvoorbeeld, voor een dagoverzicht kun je een job per gebruiker per dag afdwingen met een sleutel zoals summary:user123:2026-01-25. Als een dubbele insert plaatsvindt, verwijst die naar dezelfde job in plaats van een tweede te maken.

Markeer succes pas wanneer het neveneffect echt voltooid is (e-mail verzonden, record bijgewerkt). Als je opnieuw probeert, mag het retry-pad geen tweede e-mail of dubbele write veroorzaken.

Retries en foutafhandeling zonder drama

Verstuur herinneringen op een veiligere manier
Queue eenmalige herinneringen met idempotency-keys om duplicaten en verrassingen te verminderen.
Bouw herinneringen

Retries zijn waar jobsystemen of betrouwbaar worden of tot herrie leiden. Het doel is simpel: probeer opnieuw wanneer een fout waarschijnlijk tijdelijk is, stop wanneer dat niet zo is.

Een standaard retrybeleid bevat meestal:

  • max attempts (bijv. 5 pogingen in totaal)
  • een delay-strategie (vaste vertraging of exponentiële backoff)
  • stopcondities (retry niet bij “ongeldige input” fouten)
  • jitter (een kleine willekeurige offset om retry-spikes te vermijden)

In plaats van een nieuwe status voor retries te verzinnen, kun je vaak queued hergebruiken: zet run_at op de tijd van de volgende poging en zet de job terug in de wachtrij. Dat houdt de toestandsmachine klein.

Wanneer een job gedeeltelijke voortgang kan maken, beschouw dat als normaal. Sla een checkpoint op zodat een retry veilig kan verdergaan, hetzij in de job-payload (zoals last_processed_id) of in een gerelateerde tabel.

Voorbeeld: een dagoverzicht-job genereert berichten voor 500 gebruikers. Als hij faalt bij gebruiker 320, sla dan de laatst succesvolle gebruiker-id op en probeer opnieuw vanaf 321. Als je ook een summary_sent record per gebruiker per dag bijhoudt, kan een hertest bestaande gebruikers overslaan.

Logging die echt helpt

Log genoeg om binnen minuten te debuggen:

  • job id, type en attempt-nummer
  • sleutelinputs (user/team id, datumbereik)
  • timing (started_at, finished_at, volgende runtijd)
  • korte foutsamenvatting (plus stacktrace als je die hebt)
  • aantal neveneffecten (e-mails verzonden, rijen bijgewerkt)

Stap-voor-stap: bouw een eenvoudige scheduler-loop

Automatiseer dagelijkse overzichten
Plan dagelijkse overzichten met duidelijke tijdvensters en opgeslagen gebruikers-tijdzones.
Bouw overzichten

Een scheduler-loop is een klein proces dat op een vaste ritme wakker wordt, zoekt naar verschuldigd werk en het doorgeeft. Het doel is saaie betrouwbaarheid, geen perfecte timing. Voor veel apps is “elke minuut wakker worden” genoeg.

Kies je wake-up frequentie op basis van hoe tijdkritisch de jobs zijn en hoeveel belasting je database aankan. Als herinneringen bijna realtime moeten zijn, run elke 30 tot 60 seconden. Als dagoverzichten wat mogen schuiven, is elke 5 minuten prima en goedkoper.

Een eenvoudige loop:

  1. Word wakker en haal de huidige tijd op (gebruik UTC).
  2. Selecteer verschuldigde jobs waar status = 'queued' en run_at <= now.
  3. Claim jobs veilig zodat maar één worker ze kan nemen.
  4. Geef elke geclaimde job aan een worker.
  5. Slaap tot de volgende tick.

De claim-stap is waar veel systemen breken. Je wilt een job als running markeren (en locked_by en locked_until opslaan) in dezelfde transactie als waarin je hem selecteert. Veel databases ondersteunen 'skip locked' reads zodat meerdere schedulers kunnen draaien zonder elkaar in de weg te zitten.

-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;

Houd de batchgrootte klein (bijv. 50 tot 200). Grotere batches kunnen de database vertragen en crashes pijnlijker maken.

Als de scheduler midden in een batch crasht, redt de lease je. Jobs die vastzitten in running worden weer beschikbaar nadat locked_until is verstreken. Je worker moet idempotent zijn zodat een opnieuw geclaimde job geen dubbele e-mails of dubbele betalingen veroorzaakt.

Patronen voor herinneringen, dagoverzichten en opschoning

De meeste teams eindigen met dezelfde drie soorten achtergrondwerk: berichten die op tijd de deur uit moeten, rapporten die op schema draaien en opschoning die opslag en performance gezond houdt. Dezelfde jobs-tabel en worker-loop kunnen al deze taken afhandelen.

Herinneringen

Voor herinneringen sla je alles wat nodig is om het bericht te verzenden in de job-rij: wie het ontvangt, welk kanaal (e-mail, sms, Telegram, in-app), welke template en de exacte verzendtijd. De worker moet de job kunnen uitvoeren zonder ergens anders naar op zoek te gaan.

Als veel herinneringen tegelijk verschuldigd zijn, voeg dan rate limiting toe. Begrens berichten per minuut per kanaal en laat extra jobs wachten tot de volgende run.

Dagoverzichten

Dagoverzichten falen wanneer het tijdvenster vaag is. Kies één stabiele cutoff-tijd (bijv. 08:00 in de lokale tijd van de gebruiker) en definieer het venster duidelijk (bijv. “gisteren 08:00 tot vandaag 08:00”). Sla de cutoff en de tijdzone van de gebruiker op bij de job zodat hertests hetzelfde resultaat opleveren.

Houd elk overzicht klein. Als het duizenden records moet verwerken, splits het dan in chunks (per team, per account of per ID-bereik) en enqueue vervolg-jobs.

Opschoningstaken

Opschoning is veiliger wanneer je “verwijderen” en “archiveren” scheidt. Beslis wat voorgoed verwijderd kan worden (tijdelijke tokens, verlopen sessies) en wat gearchiveerd moet worden (auditlogs, facturen). Run opschoning in voorspelbare batches om lange locks en plotselinge loadspikes te vermijden.

Tijd en tijdzones: de verborgen bron van bugs

Krijg snel queue-inzicht
Bouw een beheervenster om queued, running en failed jobs te filteren en veilig opnieuw uit te voeren.
Maak admin

Veel fouten zijn tijdbugs: een herinnering gaat een uur te vroeg, een dagoverzicht mist maandag, of opschoning draait twee keer.

Een goed uitgangspunt is schema-tijdstempels in UTC op te slaan en de tijdzone van de gebruiker apart te bewaren. Je run_at moet een enkel UTC-moment zijn. Wanneer een gebruiker “09:00 mijn tijd” zegt, converteer dat naar UTC bij het plannen.

Zomertijd (DST) is waar naïeve setups breken. “Elke dag om 09:00” is niet hetzelfde als “elke 24 uur”. Bij DST-wissels verwijst 09:00 naar een ander UTC-moment en sommige lokale tijden bestaan niet (vooruitklok) of komen twee keer voor (achteruitklok). De veiligere aanpak is telkens de volgende lokale gebeurtenis te berekenen wanneer je opnieuw plant, en die dan weer naar UTC te converteren.

Voor een dagoverzicht, beslis vooraf wat “een dag” betekent. Een kalenderdag (middernacht tot middernacht in de tijdzone van de gebruiker) komt overeen met menselijke verwachtingen. “Laatste 24 uur” is eenvoudiger maar schuift en verrast mensen.

Laat data die laat binnenkomt onvermijdelijk zijn: een gebeurtenis arriveert na een retry, of een notitie wordt een paar minuten na middernacht toegevoegd. Beslis of late events bij “gisteren” horen (met een grace-periode) of bij “vandaag”, en houd die regel consequent.

Een praktische marge kan misses voorkomen:

  • scan naar jobs die tot 2–5 minuten geleden verschuldigd waren
  • maak de job idempotent zodat hertests veilig zijn
  • registreer het gedekte tijdsbereik in de payload zodat overzichten consistent blijven

Veelgemaakte fouten die gemiste of dubbele runs veroorzaken

De meeste pijn komt door een paar voorspelbare aannames.

De grootste is aannemen dat uitvoering “exactly once” is. In echte systemen herstarten workers, netwerkoproepen verlopen en locks kunnen verloren gaan. Meestal krijg je “at least once” levering, wat betekent dat duplicaten normaal zijn en je code ze moet tolereren.

Een andere fout is effecten eerst doen (e-mail sturen, kaart belasten) zonder een dedupe-check. Een eenvoudige guard lost dit vaak op: een sent_at timestamp, een unieke sleutel zoals (user_id, reminder_type, date), of een opgeslagen dedupe-token.

Zichtbaarheid is de volgende kloof. Als je geen antwoord kunt geven op “wat zit vast, sinds wanneer en waarom,” ga je gissen. De minimale data om dichtbij te houden is status, attempt-count, volgende geplande tijd, laatste fout en worker-id.

De fouten die het vaakst opduiken:

  • jobs ontwerpen alsof ze precies één keer draaien en dan verrast worden door duplicaten
  • neveneffecten schrijven zonder dedupe-controle
  • één enorme job draaien die alles probeert en halverwege timeouts krijgt
  • oneindig blijven retryen zonder limiet
  • basisqueue-zichtbaarheid overslaan (geen duidelijk zicht op backlog, fouten, langlopende items)

Een concreet voorbeeld: een dagoverzicht-job loopt 50.000 gebruikers langs en time-outt bij gebruiker 20.000. Bij retry begint hij opnieuw en stuurt opnieuw overzichten naar de eerste 20.000 tenzij je per-gebruiker voltooiing bijhoudt of het opsplitst in per-gebruiker jobs.

Snelle checklist voor een betrouwbaar job-systeem

Vervang cron door een job-runner
Bouw een jobs-tabel en worker-flow met visuele logica en PostgreSQL-modelering.
Probeer AppMaster

Een job-runner is pas “klaar” als je hem om 02:00 kunt vertrouwen.

Zorg dat je hebt:

  • Queue-zichtbaarheid: aantallen voor queued vs running vs failed, plus de oudste queued job.
  • Idempotentie als standaard: ga uit van dubbele runs; gebruik unieke sleutels of “al verwerkt” markers.
  • Retrybeleid per jobtype: retries, backoff en een duidelijke stopconditie.
  • Consistente tijdopslag: bewaar run_at in UTC; converteer alleen bij invoer en weergave.
  • Hernieuwbare locks: een lease zodat crashes geen jobs voor altijd in running laten zitten.

Beperk ook batchgrootte (hoeveel jobs je tegelijk claimt) en worker-concurrency (hoeveel tegelijk draaien). Zonder limieten kan een piek je database overbelasten of ander werk verdringen.

Een realistisch voorbeeld: herinneringen en overzichten voor een klein team

Behandel fouten zonder drama
Gebruik attempts, backoff-timing en stopcondities als onderdeel van je job-workflow.
Voeg retries toe

Een kleine SaaS heeft 30 klantaccounts. Elk account wil twee dingen: een 09:00 herinnering voor openstaande taken en een 18:00 dagoverzicht van wat er vandaag veranderde. Ze hebben ook wekelijkse opschoning nodig zodat de database niet volloopt met oude logs en verlopen tokens.

Ze gebruiken een jobs-tabel plus een worker die naar verschuldigde jobs pollt. Wanneer een nieuwe klant zich aanmeldt, plant de backend de eerste reminder- en overzicht-runs op basis van de tijdzone van de klant.

Jobs worden op een paar veelvoorkomende momenten aangemaakt: bij signup (maak terugkerende schema's), bij bepaalde events (enqueue one-off notificaties), bij een schedule-tick (insert aankomende runs) en op onderhoudsdag (enqueue opschoning).

Op een dinsdag heeft de e-mailprovider om 08:59 een tijdelijke storing. De worker probeert herinneringen te verzenden, krijgt een timeout en plant die jobs opnieuw in door run_at aan te passen volgens backoff (bijv. 2 minuten, dan 10, dan 30), en verhoogt attempts elke keer. Omdat elke reminder-job een idempotency-key heeft zoals account_id + date + job_type, veroorzaken retries geen duplicaten als de provider halverwege herstelt.

Opschoning draait wekelijks in kleine batches, zodat het ander werk niet blokkeert. In plaats van een miljoen rijen in één job te verwijderen, verwijdert het tot N rijen per run en plant zichzelf opnieuw totdat het klaar is.

Als een klant klaagt “Ik heb mijn overzicht nooit gekregen”, controleert het team de jobs-tabel voor dat account en die dag: jobstatus, attempt-count, huidige lockvelden en de laatste fout van de provider. Daarmee verandert “het had moeten sturen” in “dit is precies wat er gebeurde.”

Volgende stappen: implementeer, observeer en scale

Kies één jobtype en bouw het end-to-end voordat je meer toevoegt. Een enkele reminder-job is een goede starter omdat het alles raakt: plannen, claimen van verschuldigd werk, een bericht sturen en uitkomsten registreren.

Begin met een versie waarop je kunt vertrouwen:

  • maak de jobs-tabel en één worker die één jobtype verwerkt
  • voeg een scheduler-loop toe die verschuldigde jobs claimt en uitvoert
  • bewaar genoeg payload om de job zonder extra gissingen uit te voeren
  • log elke poging en uitkomst zodat “Is het gedraaid?” een vraag van 10 seconden wordt
  • voeg een handmatig opnieuw-run-pad toe voor mislukte jobs zodat recovery geen deploy vereist

Als het draait, maak het observeerbaar voor mensen. Zelfs een basis admin-view betaalt zich snel terug: zoek jobs op status, filter op tijd, inspecteer payload, annuleer een vastzittende job, voer een specifieke job-id opnieuw uit.

Als je dit soort scheduler- en worker-flow liever met visuele backendlogica bouwt, kan AppMaster (appmaster.io) de jobs-tabel modelleren in PostgreSQL en de claim-process-update loop als een Business Process implementeren, terwijl er nog steeds echte broncode voor deployment wordt gegenereerd.

Gemakkelijk te starten
Maak iets geweldigs

Experimenteer met AppMaster met gratis abonnement.
Als je er klaar voor bent, kun je het juiste abonnement kiezen.

Aan de slag