12 aug 2025·7 min leestijd

Triggers vs achtergrondwerkers voor betrouwbare meldingen

Leer wanneer triggers of achtergrondwerkers veiliger zijn voor meldingen, met praktische richtlijnen over retries, transacties en het voorkomen van duplicaten.

Triggers vs achtergrondwerkers voor betrouwbare meldingen

Waarom notificatiebezorging faalt in echte apps

Notificaties klinken simpel: een gebruiker doet iets, en er gaat een e-mail of sms uit. De meeste echte fouten komen neer op timing en duplicatie. Berichten worden verzonden voordat de data echt is opgeslagen, of ze worden twee keer verzonden na een gedeeltelijke fout.

Een “notificatie” kan veel dingen zijn: e-mailontvangsten, SMS-eenmalige codes, pushmeldingen, in-app berichten, Slack- of Telegram-pings, of een webhook naar een ander systeem. Het gedeelde probleem is altijd hetzelfde: je probeert een databasewijziging te coördineren met iets buiten je app.

De buitenwereld is rommelig. Providers kunnen traag zijn, time-outs teruggeven, of een verzoek accepteren terwijl je app nooit de succesrespons ontvangt. Je eigen app kan crashen of herstarten middenin het verzoek. Zelfs “succesvolle” sends kunnen opnieuw worden uitgevoerd vanwege infrastructuurretries, worker-restarts of een gebruiker die op de knop drukt.

Veelvoorkomende oorzaken van kapotte notificatiebezorging zijn netwerk-timeouts, providerstoringen of rate limits, app-herstarts op het verkeerde moment, retries die dezelfde send-logica zonder unieke guard opnieuw uitvoeren, en ontwerpen waarbij een database-write en een externe send als één gecombineerde stap gebeuren.

Als mensen vragen om “betrouwbare notificaties”, bedoelen ze meestal een van twee dingen:

  • precies één keer afleveren, of
  • in ieder geval nooit dupliceren (duplicaten zijn vaak erger dan vertraging).

Beide snel en perfect veilig krijgen is moeilijk, dus je maakt keuzes tussen snelheid, veiligheid en complexiteit.

Daarom is de keuze tussen triggers en achtergrondwerkers niet alleen een architectuurdebat. Het gaat erom wanneer een verzendactie mag plaatsvinden, hoe fouten worden hersteld, en hoe je dubbele e-mails of sms voorkomt als er iets misgaat.

Triggers en achtergrondwerkers: wat ze betekenen

Als mensen triggers vergelijken met achtergrondwerkers, vergelijken ze eigenlijk waar de notificatielogica draait en hoe nauw die verbonden is met de actie die het veroorzaakte.

Een trigger is “doe het nu wanneer X gebeurt.” In veel apps betekent dat het verzenden van een e-mail of sms direct na een gebruikersactie, binnen hetzelfde webverzoek. Triggers kunnen ook op database-niveau leven: een database-trigger draait automatisch wanneer een rij wordt ingevoegd of bijgewerkt. Beide typen voelen meteen aan, maar ze erven de timing en beperkingen van wat ze heeft afgevuurd.

Een achtergrondwerker is “doe het binnenkort, maar niet op de voorgrond.” Het is een apart proces dat jobs uit een wachtrij pakt en probeert ze af te handelen. Je hoofdapp registreert wat er moet gebeuren en geeft snel terug, terwijl de worker de tragere, foutgevoelige delen afhandelt zoals het aanroepen van een e-mail- of sms-provider.

Een “job” is de eenheid werk die de worker verwerkt. Die bevat meestal wie te informeren is, welke template, welke data ingevuld moet worden, de huidige status (queued, processing, sent, failed), hoeveel pogingen er zijn geweest en soms een ingeplande tijd.

Een typisch notificatieproces ziet er zo uit: je bereidt berichtdetails voor, zet een job in de wachtrij, verstuurt via een provider, registreert het resultaat en besluit vervolgens of je het probeert opnieuw, stopt of iemand alarmeert.

Transactiegrenzen: wanneer het echt veilig is om te verzenden

Een transactiegrens is de scheidslijn tussen “we hebben geprobeerd het op te slaan” en “het is echt opgeslagen.” Totdat de database commit, kan de wijziging nog teruggedraaid worden. Dat doet ertoe omdat notificaties moeilijk terug te draaien zijn.

Als je een e-mail of sms verzendt vóór de commit, kun je iemand berichten over iets dat nooit heeft plaatsgevonden. Een klant kan "Je wachtwoord is gewijzigd" of "Je bestelling is bevestigd" krijgen, en dan faalt de write door een constraintfout of timeout. Nu is de gebruiker in de war en moet de supportafdeling het uitzoeken.

Verzenden vanuit een database-trigger lijkt aantrekkelijk omdat het automatisch afvuurt wanneer data verandert. Het nadeel is dat triggers binnen dezelfde transactie draaien. Als de transactie teruggedraaid wordt, heb je mogelijk al een e-mail- of sms-provider aangeroepen.

Database-triggers zijn ook vaak lastiger te observeren, testen en veilig opnieuw te proberen. En wanneer ze trage externe calls uitvoeren, kunnen ze locks langer vasthouden dan verwacht en databaseproblemen moeilijker te diagnosticeren maken.

Een veiliger aanpak is het outbox-patroon: registreer de intentie om te notificeren als data, commit dat, en verstuur het daarna.

Je doet de zakelijke wijziging en voegt in dezelfde transactie een outbox-rij toe die het bericht beschrijft (ontvanger, wat, welk kanaal, plus een unieke sleutel). Na commit leest een achtergrondwerker de pending outbox-rijen, verstuurt het bericht en markeert ze als verzonden.

Directe sends kunnen nog steeds prima zijn voor laag-impact informatieve berichten waarbij foutief zijn acceptabel is, zoals “We verwerken je aanvraag.” Voor alles wat overeen moet komen met de uiteindelijke staat, wacht tot na de commit.

Retries en foutafhandeling: waar elke aanpak wint

Retries zijn meestal de beslissende factor.

Triggers: snel, maar breekbaar bij fouten

De meeste trigger-gebaseerde ontwerpen hebben geen goede retry-verhaallijn.

Als een trigger een e-mail/sms-provider aanroept en de call faalt, heb je meestal twee slechte keuzes:

  • de transactie laten falen (en de oorspronkelijke update blokkeren), of
  • de fout negeren (en de notificatie stilletjes verliezen).

Geen van beide is acceptabel wanneer betrouwbaarheid belangrijk is.

Proberen te loopen of uit te stellen binnen een trigger kan het erger maken doordat transacties langer open blijven, locktijden toenemen en de database trager wordt. En als de database of app crasht tijdens het verzenden, kun je vaak niet vertellen of de provider het verzoek heeft ontvangen.

Achtergrondwerkers: ontworpen voor retries

Een worker behandelt verzenden als een afzonderlijke taak met zijn eigen staat. Daardoor is het natuurlijk om alleen te retryen wanneer dat zinvol is.

Als praktische regel retry je meestal tijdelijke fouten (timeouts, tijdelijke netwerkproblemen, serverfouten, rate limits met langere wachttijd). Je retryt doorgaans geen permanente problemen (ongeldige telefoonnummers, verkeerd gevormde e-mails, harde afwijzingen zoals afgemelde gebruikers). Voor “onbekende” fouten begrens je pogingen en maak je de staat zichtbaar.

Backoff voorkomt dat retries het erger maken. Begin met een korte wachttijd en verhoog die elke keer (bijvoorbeeld 10s, 30s, 2m, 10m) en stop na een vast aantal pogingen.

Om dit te laten overleven bij deploys en restarts, sla je retry-state op bij elke job: pogingsaantal, volgende pogingstijd, laatste fout (kort en leesbaar), laatste pogingstijd en een duidelijke status zoals pending, sending, sent, failed.

Als je app tijdens het verzenden herstart, kan een worker vastgelopen jobs hercontroleren (bijvoorbeeld status = sending met een oude timestamp) en ze veilig opnieuw proberen. Hier wordt idempotentie essentieel zodat een retry niet dubbel verzendt.

Duplicaten voorkomen met idempotentie

Maak verzendingen observeerbaar
Modelleer notificatiejobs in PostgreSQL en houd retries en status zichtbaar.
Begin met bouwen

Idempotentie betekent dat je dezelfde “stuur notificatie”-actie meerdere keren kunt uitvoeren en de gebruiker het bericht toch maar één keer krijgt.

De klassieke duplicatiecasus is een timeout: je app roept een e-mail- of sms-provider aan, het verzoek time-outt en je code probeert opnieuw. Het eerste verzoek kan echter al wel gelukt zijn, dus de retry maakt een duplicaat.

Een praktische oplossing is elk bericht een stabiele sleutel te geven en die sleutel als enige bron van waarheid te behandelen. Goede sleutels beschrijven wat het bericht betekent, niet wanneer je het probeerde te verzenden.

Veelvoorkomende benaderingen zijn onder andere:

  • een gegenereerde notification_id die je creëert wanneer je beslist "dit bericht moet bestaan", of
  • een op business gebaseerde sleutel zoals order_id + template + recipient (alleen als dat echt uniciteit definieert).

Sla daarna een send-ledger op (vaak de outbox-tabel zelf) en laat alle retries daarop controleren voordat je verzendt. Houd statussen eenvoudig en zichtbaar: created (besloten), queued (klaar), sent (bevestigd), failed (bevestigde fout), canceled (niet meer nodig). De kritieke regel is dat je maar één actieve record per idempotency-key toestaat.

Provider-zijde idempotentie kan helpen wanneer het wordt ondersteund, maar het vervangt je eigen ledger niet. Je moet nog steeds je retries, deployments en worker-restarts afhandelen.

Behandel ook “onbekende” uitkomsten als volwaardige gevallen. Als een request time-outt, stuur dan niet meteen opnieuw. Markeer het als pending bevestiging en probeer veilig opnieuw door de bezorgstatus bij de provider te controleren wanneer mogelijk. Als je niet kunt bevestigen, stel dan uit en alarmmeer in plaats van dubbel te sturen.

Een veilige standaard: outbox + achtergrondwerker (stap voor stap)

Als je een veilige standaard wilt, is het outbox-patroon plus een worker moeilijk te verslaan. Het houdt verzenden buiten je zakelijke transactie terwijl de intentie om te notificeren wel gegarandeerd is opgeslagen.

De flow

Behandel “stuur een notificatie” als data die je opslaat, niet als een actie die je afvuurt.

Je slaat de zakelijke wijziging op (bijvoorbeeld een orderstatusupdate) in je normale tabellen. In dezelfde database-transactie voeg je ook een outbox-record toe met ontvanger, kanaal (e-mail/sms), template, payload en een idempotency-key. Je commit de transactie. Pas na dat punt mag er iets verzonden worden.

Een achtergrondwerker pakt regelmatig pending outbox-rijen, verstuurt ze en registreert het resultaat.

Voeg een simpele claiming-stap toe zodat twee workers niet dezelfde rij pakken. Dat kan een statusverandering naar processing zijn of een gelockte timestamp.

Duplicaten blokkeren en fouten afhandelen

Duplicaten ontstaan vaak wanneer een send succesvol is maar je app crasht voordat het "sent" wordt vastgelegd. Je lost dat op door de "mark sent"-schrijfactie herhaalbaar veilig te maken.

Gebruik een uniqueness-regel (bijvoorbeeld een unieke constraint op de idempotency-key en het kanaal). Retry met duidelijke regels: beperkte pogingen, toenemende vertragingen en alleen voor retrybare fouten. Na de laatste retry verplaats je de job naar een dead-letter-status (zoals failed_permanent) zodat iemand het kan beoordelen en handmatig kan verwerken.

Monitoring kan simpel blijven: aantallen van pending, processing, sent, retrying en failed_permanent, plus de oudste pending-timestamp.

Concreet voorbeeld: wanneer een bestelling van “Packed” naar “Shipped” gaat, update je de order-rij en creëer je één outbox-rij met idempotency-key order-4815-shipped. Zelfs als de worker crasht tijdens het verzenden, zal een herstart geen dubbele verzending veroorzaken omdat de "sent"-schrijfactie beschermd is door die unieke key.

Wanneer achtergrondwerkers de betere keuze zijn

Implementeer de veilige standaard
Prototypeer het volledige patroon snel: datamodel, businesslogica en UI voor monitoring.
Probeer AppMaster

Database-triggers reageren direct op datawijzigingen. Maar als de taak is “bezorg een notificatie betrouwbaar onder rommelige real-world condities”, geven achtergrondwerkers je meestal meer controle.

Workers passen beter wanneer je tijd gebaseerde sends nodig hebt (herinneringen, digests), hoge volumes met rate limits en backpressure, tolerantie voor providervariabiliteit (429-limieten, trage responses, korte storingen), multi-step workflows (stuur, wacht op levertijd, volg op), of cross-systeem events die reconciliatie vereisen.

Een eenvoudig voorbeeld: je incasseert een klant, stuurt dan een sms-kwitantie en daarna een factuur per e-mail. Als SMS faalt door een gateway-issue, wil je dat de bestelling betaald blijft en dat er veilig later wordt herprobeerd. Die logica in een trigger steken loopt het risico om "data is correct" te mengen met "een derde partij is nu beschikbaar".

Achtergrondwerkers maken ook operationele controle makkelijker. Je kunt een wachtrij pauzeren tijdens een incident, fouten inspecteren en met vertraging opnieuw proberen.

Veelgemaakte fouten die gemiste of dubbele berichten veroorzaken

Verstuur buiten het webverzoek
Verplaats provider-aanroepen uit het request-pad met een achtergrondproces.
Bouw nu

De snelste manier om onbetrouwbare notificaties te krijgen is "verzend het gewoon" waar het convenient voelt en hopen dat retries het oplossen. Of je nu triggers of workers gebruikt, de details rond falen en staat bepalen of gebruikers één bericht, twee berichten of geen bericht krijgen.

Een veel voorkomende valkuil is verzenden vanuit een database-trigger en aannemen dat het niet kan mislukken. Triggers draaien binnen de database-transactie, dus elk trage provider-aanroep kan de write vertragen, time-outs veroorzaken of tabellen langer vergrendelen dan verwacht. Nog erger, als de send faalt en je de transactie terugdraait, kun je later opnieuw proberen en twee keer sturen als de provider het eerste verzoek eigenlijk heeft geaccepteerd.

Fouten die vaak terugkomen:

  • Alles op dezelfde manier opnieuw proberen, inclusief permanente fouten (slecht e-mailadres, geblokkeerd nummer).
  • Niet scheiden van “queued” en “sent”, zodat je niet weet wat veilig is om opnieuw te proberen na een crash.
  • Timestamps gebruiken als dedupe-keys, waardoor retries vanzelf uniciteit omzeilen.
  • Provider-calls in de gebruikersrequest-path doen (checkout en formulierverzendingen zouden niet op gateways moeten wachten).
  • Provider-timeouts behandelen als "niet afgeleverd", terwijl veel gevallen eigenlijk "onbekend" zijn.

Een eenvoudig voorbeeld: je stuurt een sms, de provider time-outt, en je retryt. Als het eerste verzoek daadwerkelijk geslaagd was, krijgt de gebruiker twee codes. De oplossing is een stabiele idempotency-key te registreren (zoals een notification_id), het bericht als queued te markeren voordat je verzendt en het pas als sent te markeren na een duidelijke succesrespons.

Snelle checks voordat je notificaties uitrolt

De meeste notificatiebugs gaan niet over het hulpmiddel, maar over timing, retries en ontbrekende registraties.

Bevestig dat je alleen verzendt nadat de database-write veilig is gecommit. Als je tijdens dezelfde transactie verzendt en die later teruggedraaid wordt, kunnen gebruikers een bericht krijgen over iets dat nooit heeft plaatsgevonden.

Geef elk bericht een unieke identificeerbare sleutel. Geef elke notificatie een stabiele idempotency-key (bijvoorbeeld order_id + event_type + channel) en dwing die af in opslag zodat een retry geen tweede "nieuwe" notificatie kan maken.

Controleer vóór release deze basics:

  • Verzenden gebeurt na commit, niet tijdens het schrijven.
  • Elke notificatie heeft een unieke idempotency-key en duplicaten worden geweigerd.
  • Retries zijn veilig: het systeem kan dezelfde job opnieuw uitvoeren en toch hooguit één keer verzenden.
  • Elke poging wordt geregistreerd (status, last_error, timestamps).
  • Pogingen zijn begrensd en vastzittende items hebben een duidelijke plek om te bekijken en opnieuw te verwerken.

Test restart-gedrag bewust. Kill de worker halverwege een send, start hem opnieuw en verifieer dat er niets dubbel verzonden wordt. Doe hetzelfde terwijl de database onder load staat.

Een eenvoudig scenario om te valideren: een gebruiker wijzigt zijn telefoonnummer en je stuurt een sms-verificatie. Als de sms-provider time-outt, probeert je app opnieuw. Met een goede idempotency-key en poginglog stuur je ofwel eenmaal of probeer je veilig later nog eens, maar je spamt niet.

Voorbeeldscenario: orderupdates zonder dubbel verzenden

Houd triggers lichtgewicht
Stel e-mail-, sms- of Telegram-berichten in zonder ze te mengen met database-triggers.
Probeer AppMaster

Een winkel stuurt twee soorten berichten: (1) een orderbevestiging per e-mail direct na betaling, en (2) sms-updates wanneer het pakket onderweg en afgeleverd is.

Dit gaat mis als je te vroeg verzendt (bijvoorbeeld in een database-trigger): de betaalstap schrijft een orders-rij, de trigger vuurt en mailt de klant, en daarna faalt de betalingsecond-capture. Nu heb je een "Dank voor je bestelling"-mail voor een bestelling die nooit echt is gekomen.

Stel nu het tegenovergestelde probleem voor: de leveringsstatus verandert naar "Onderweg", je belt je sms-provider en die time-outt. Je weet niet of het bericht is verzonden. Als je meteen opnieuw probeert, loop je het risico op twee sms'jes. Als je niet opnieuw probeert, loop je het risico helemaal niets te sturen.

Een veiliger flow gebruikt een outbox-rij plus een achtergrondwerker. De app commit de order- of statuswijziging en schrijft in dezelfde transactie een outbox-rij zoals "stuur template X naar gebruiker Y, kanaal SMS, idempotency key Z." Pas na commit levert een worker de berichten.

Een simpele tijdlijn ziet er zo uit:

  • Betaling slaagt, transactie commit, outbox-rij voor de bevestigingsmail is opgeslagen.
  • Worker stuurt de e-mail en markeert de outbox als verzonden met een provider message ID.
  • Leveringsstatus verandert, transactie commit, outbox-rij voor de sms-update is opgeslagen.
  • Provider time-outt, worker markeert de outbox als retryable en probeert later opnieuw met dezelfde idempotency-key.

Bij retry is de outbox-rij de enige bron van waarheid. Je maakt geen tweede "send"-request; je maakt de eerste af.

Voor support is dit ook duidelijker. Zij kunnen berichten zien die vastzitten in "failed" met de laatste fout (timeout, slecht telefoonnummer, geblokkeerde e-mail), hoeveel pogingen er zijn gedaan en of het veilig is om opnieuw te proberen zonder dubbel te sturen.

Volgende stappen: kies een patroon en implementeer het schoon

Kies een standaard en leg het vast. Inconsistent gedrag komt meestal voort uit het willekeurig mixen van triggers en workers.

Begin klein met een outbox-tabel en één worker-loop. Het eerste doel is niet snelheid, maar correctheid: sla op wat je van plan bent te versturen, verstuur het na commit en markeer het pas als verzonden wanneer de provider bevestigt.

Een eenvoudig uitrolplan:

  • Definieer events (order_paid, ticket_assigned) en welke kanalen ze kunnen gebruiken.
  • Voeg een outbox-tabel toe met event_id, recipient, payload, status, attempts, next_retry_at, sent_at.
  • Bouw één worker die pending rijen pollet, verzendt en de status op één plek bijwerkt.
  • Voeg idempotentie toe met een unieke sleutel per bericht en "doe niets als het al verzonden is."
  • Verdeel fouten in retryable (timeouts, 5xx) vs niet-retryable (slecht nummer, geblokkeerde e-mail).

Voordat je volume opschaalt, voeg basis-visibility toe. Houd het aantal pending items, foutpercentages en de leeftijd van het oudste pending-bericht bij. Als het oudste pending-bericht blijft groeien, heb je waarschijnlijk een vastzittende worker, providerstoring of logische bug.

Als je in AppMaster (appmaster.io) bouwt, mappt dit patroon netjes: modelleer de outbox in de Data Designer, schrijf de businesswijziging en de outbox-rij in één transactie en voer daarna de send-en-retry-logica uit in een apart achtergrondproces. Die scheiding houdt notificatiebezorging betrouwbaar, zelfs als providers of deploys zich vreemd gedragen.

FAQ

Moet ik triggers of achtergrondwerkers gebruiken voor notificaties?

Achtergrondwerkers zijn meestal de veiligere standaard omdat verzenden traag en foutgevoelig is, en workers zijn gebouwd voor retries en observatie. Triggers kunnen snel zijn, maar ze zitten nauw verweven met de transactie of het verzoek dat ze aanroept, waardoor fouten en duplicaten lastiger en minder netjes af te handelen zijn.

Waarom is het riskant om een notificatie te versturen vóór de databasecommit?

Het is gevaarlijk omdat de database-write nog kan teruggedraaid worden. Je kunt gebruikers informeren over een bestelling, wachtwoordwijziging of betaling die nooit echt is gecommit, en een e-mail of sms kun je niet ongedaan maken nadat die de deur uit is.

Wat is het grootste probleem van verzenden vanuit een database-trigger?

Een database-trigger draait binnen dezelfde transactie als de rijwijziging. Als de trigger een e-mail/sms-provider aanroept en de transactie later faalt, heb je mogelijk al daadwerkelijk een bericht gestuurd over een wijziging die niet is doorgevoerd, of je kunt de transactie laten vastlopen door een trage externe call.

Wat is het outbox-patroon in eenvoudige bewoordingen?

Het outbox-patroon slaat de intentie om te verzenden op als een rij in je database, in dezelfde transactie als de businesswijziging. Na commit leest een worker de pending outbox-rijen, verstuurt het bericht en markeert het als verzonden, waardoor timing en retries veel veiliger worden.

Wat moet ik doen wanneer een e-mail/sms-providerrequest time-out?

Bij een timeout is de uitkomst vaak "onbekend", niet per se "mislukt". Een goed systeem registreert de poging, vertraagt en retryt veilig met dezelfde berichtidentiteit, in plaats van direct opnieuw te sturen en zo een duplicaat te riskeren.

Hoe voorkom ik dubbele e-mails of sms'jes wanneer retries plaatsvinden?

Gebruik idempotentie: geef elke notificatie een stabiele key die beschrijft wat het bericht betekent (niet wanneer je het probeerde). Sla die key op in een log (meestal de outbox-tabel) en zorg dat er maar één actieve record per key is, zodat retries hetzelfde bericht afmaken in plaats van een nieuw bericht te creëren.

Welke fouten moet ik herproberen vs als permanent behandelen?

Retry tijdelijke fouten zoals timeouts, 5xx-responses of rate limits (met wachttijd). Retry geen permanente fouten zoals ongeldige adressen, geblokkeerde nummers of harde bounces; markeer die als mislukt en maak ze zichtbaar zodat iemand de data kan corrigeren in plaats van eindeloos te proberen.

Hoe gaan achtergrondwerkers om met restarts of crashes halverwege het verzenden?

Een achtergrondwerker kan jobs scannen die vastzitten in sending voorbij een redelijke timeout, ze terugzetten naar retryable en opnieuw proberen met backoff. Dit werkt veilig alleen als elke job statusgegevens heeft (pogingsaantal, timestamps, laatste fout) en idempotentie dubbele verzendingen voorkomt.

Welke jobgegevens heb ik nodig om notificatiebezorging observeerbaar te maken?

Dat betekent dat je niet kunt beantwoorden of het veilig is om te retryen zonder gegevens. Sla duidelijke statussen op zoals pending, processing, sent en failed, plus pogingsaantal en laatste fout. Dat maakt support en debugging praktisch en laat je systeem herstellen zonder te raden.

Hoe zou ik dit patroon implementeren in AppMaster?

Modelleer een outbox-tabel in de Data Designer, schrijf de business-update en de outbox-rij in één transactie, en voer daarna de send-en-retry-logica uit in een apart achtergrondproces. Houd één idempotency-key per bericht en registreer pogingen zodat deploys, retries en worker-restarts geen duplicaten veroorzaken.

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