Racecondities in CRUD-apps kunnen leiden tot dubbele bestellingen en verkeerde totalen. Leer veelvoorkomende botsingspunten en fixes met database-constraints, locks en UX-guards.

Een raceconditie ontstaat wanneer twee (of meer) requests vrijwel tegelijk dezelfde data bijwerken, en het uiteindelijke resultaat afhangt van timing. Elke request ziet er op zichzelf correct uit. Samen geven ze een verkeerd resultaat.
Een eenvoudig voorbeeld: twee mensen klikken binnen een seconde op Opslaan op hetzelfde klantrecord. De één wijzigt het e-mailadres, de ander het telefoonnummer. Als beide requests het volledige record sturen, kan de tweede write de eerste overschrijven en verdwijnt één wijziging zonder foutmelding.
Je ziet dit vaker in snelle apps omdat gebruikers meer acties per minuut kunnen triggeren. Het piekt ook tijdens drukke momenten: flash sales, einde-van-de-maand rapportage, een grote e-mailcampagne, of elke keer dat een achterstand van requests dezelfde rijen raakt.
Gebruikers melden zelden "een raceconditie." Ze melden symptomen: dubbele bestellingen of reacties, ontbrekende updates ("Ik heb opgeslagen, maar het stond weer terug"), vreemde totalen (voorraad wordt negatief, tellers gaan achteruit), of statussen die onverwacht flippen (goedgekeurd en dan weer in afwachting).
Retries maken het erger. Mensen dubbelklikken, verversen na een trage response, submitten vanuit twee tabs, of hebben een flaky netwerk dat browsers en mobiele apps doet herhalen. Als de server elke request als een nieuwe write ziet, kun je twee creates, twee betalingen of twee statuswijzigingen krijgen die maar één keer bedoeld waren.
De meeste CRUD-apps voelen simpel: lees een rij, verander een veld, sla op. Het probleem is dat je app de timing niet controleert. De database, het netwerk, retries, achtergrondwerk en gebruikersgedrag overlappen allemaal.
Een veelvoorkomende trigger is dat twee mensen hetzelfde record bewerken. Beiden laden dezelfde "huidige" waarden, beiden maken valide wijzigingen, en de laatste save overschrijft stil de eerste. Niemand deed iets fout, maar één update gaat verloren.
Het gebeurt ook bij één persoon. Een dubbelklik op een Opslaan-knop, heen en weer tikken, of een trage verbinding die iemand aanspoort opnieuw op Verzenden te drukken kan dezelfde write twee keer sturen. Als het endpoint niet idempotent is, kun je duplicaten maken, twee keer in rekening brengen, of een status twee stappen vooruit zetten.
Modern gebruik voegt meer overlap toe. Meerdere tabs of apparaten die met hetzelfde account zijn aangemeld kunnen conflicterende updates afvuren. Background jobs (e-mails, billing, sync, cleanup) kunnen dezelfde rijen aanraken als webrequests. Automatische retries op de client, load balancer of job runner kunnen een request herhalen dat al geslaagd is.
Als je features snel uitrolt, wordt hetzelfde record vaak vanaf meer plekken bijgewerkt dan iemand zich herinnert. Als je een chatgestuurde builder zoals Koder.ai gebruikt, kan de app nog sneller groeien, dus behandel concurrency als normaal gedrag, niet als een randgeval.
Racecondities verschijnen zelden in "maak een record" demo's. Ze verschijnen waar twee requests vrijwel tegelijk hetzelfde stuk waarheid aanraken. Het kennen van de gebruikelijke hotspots helpt je veilige writes te ontwerpen vanaf dag één.
Alles dat voelt als "gewoon +1" kan falen onder load: likes, weergavetellers, totalen, factuurnummers, ticketnummers. Het risicovolle patroon is: lees de waarde, tel erbij op en schrijf terug. Twee requests kunnen dezelfde beginwaarde lezen en elkaar overschrijven.
Workflows zoals Concept -> Ingediend -> Goedgekeurd -> Betaald lijken eenvoudig, maar botsingen komen veel voor. Problemen ontstaan wanneer twee acties tegelijk mogelijk zijn (goedkeuren en bewerken, annuleren en betalen). Zonder bewakers kun je een record krijgen dat stappen overslaat, terugflippert of verschillende statussen in verschillende tabellen toont.
Behandel statuswijzigingen als een contract: sta alleen de volgende geldige stap toe en weiger alles wat daarbuiten valt.
Beschikbare stoelen, voorraadcounts, afspraakslots en "resterende capaciteit" creëren het klassieke oversell-probleem. Twee kopers checken tegelijk en zien allebei beschikbaarheid, en beide slagen. Als de database niet de uiteindelijke beslisser is, verkoop je uiteindelijk meer dan je hebt.
Sommige regels zijn absoluut: één e-mail per account, één actieve subscription per gebruiker, één open winkelwagen per gebruiker. Deze falen vaak als je eerst controleert ("bestaat er al één?") en dan invoegt. Onder concurrency kunnen beide requests de check passeren.
Als je CRUD-flows snel bouwt (bijvoorbeeld door je app in chat te genereren met Koder.ai), schrijf deze hotspots vroeg op en ondersteun ze met constraints en veilige writes, niet alleen UI-checks.
Veel racecondities beginnen met iets saais: dezelfde actie wordt twee keer gestuurd. Gebruikers dubbelklikken. Het netwerk is traag dus ze klikken opnieuw. Een telefoon registreert twee taps. Soms is het niet eens opzettelijk: de pagina verversen na een POST en de browser biedt aan het formulier opnieuw te versturen.
Als dat gebeurt, kan je backend twee creates of updates parallel uitvoeren. Als beide slagen, krijg je duplicaten, verkeerde totalen of een statuswijziging die twee keer plaatsvindt (bijvoorbeeld twee keer goedkeuren). Het lijkt willekeurig omdat het van timing afhangt.
De veiligste aanpak is defense in depth. Maak de UI goed, maar ga ervan uit dat de UI kan falen.
Praktische veranderingen die je op de meeste write-flows kunt toepassen:
Voorbeeld: een gebruiker tikt twee keer op "Betaal factuur" op mobiel. De UI moet de tweede tik blokkeren. De server moet ook de tweede request afwijzen als hij dezelfde idempotency key ziet en in plaats daarvan het originele succesresultaat teruggeven in plaats van twee keer te debiteren.
Statusvelden voelen simpel tot twee dingen proberen ze tegelijk te veranderen. Een gebruiker klikt Goedkeuren terwijl een geautomatiseerde job hetzelfde record Verlopen markeert, of twee teamleden werken hetzelfde item in verschillende tabs. Beide updates kunnen slagen, maar de uiteindelijke status hangt van timing af, niet van je regels.
Behandel status als een kleine toestandmachine. Houd een korte tabel met toegestane moves (bijvoorbeeld: Concept -> Ingediend -> Goedgekeurd, en Ingediend -> Afgewezen). Dan controleert elke write: "Is deze stap toegestaan vanaf de huidige status?" Zo niet, wijs het af in plaats van stil te overschrijven.
Optimistic locking helpt om verouderde updates te detecteren zonder andere gebruikers te blokkeren. Voeg een versienummer (of updated_at) toe en eis dat het overeenkomt bij opslaan. Als iemand anders de rij heeft veranderd nadat je hem hebt geladen, raakt je update geen rijen en kun je een duidelijke melding tonen zoals: "Dit item is gewijzigd, ververs en probeer het opnieuw."
Een eenvoudig patroon voor statusupdates is:
Houd statuswijzigingen ook op één plek. Als updates verspreid zijn over schermen, background jobs en webhooks, mis je gemakkelijk een regel. Zet ze achter één functie of endpoint dat telkens dezelfde transitielogica afdwingt.
De meest voorkomende counter-bug ziet er onschuldig uit: de app leest een waarde, telt 1 op en schrijft terug. Onder load kunnen twee requests hetzelfde nummer lezen en beide hetzelfde nieuwe nummer schrijven, waardoor één increment verloren gaat. Dit is makkelijk te missen omdat het meestal wél werkt in tests.
Als een waarde alleen wordt verhoogd of verlaagd, laat de database het in één statement doen. Dan past de database veranderingen veilig toe, zelfs bij veel gelijktijdige requests.
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
Hetzelfde idee geldt voor voorraad, viewtellers, retry-counters en alles wat uit te drukken is als "nieuw = oud + delta".
Totalen gaan vaak mis wanneer je een afgeleid getal opslaat (order_total, account_balance, project_hours) en het vervolgens vanaf meerdere plekken bijwerkt. Als je het totaal kunt berekenen uit bronrijen (regels, boekingen), voorkom je een hele klasse drift-bugs.
Als je een totaal moet opslaan voor performance, behandel het als een kritieke write. Hou updates naar bronrijen en het opgeslagen totaal in dezelfde transactie. Zorg dat maar één writer tegelijk hetzelfde totaal kan updaten (locking, guarded updates of een enkele eigenaar-pad). Voeg constraints toe die onmogelijke waarden voorkomen (bijvoorbeeld geen negatieve voorraad). Voer af en toe een reconciliation uit die het opnieuw berekent en mismatches markeert.
Een concreet voorbeeld: twee gebruikers voegen items toe aan dezelfde winkelwagen tegelijk. Als elke request cart_total leest, de artikelprijs erbij optelt en terugschrijft, kan één toevoeging verdwijnen. Als je de winkelwagenitems en het cart total samen in één transactie bijwerkt, blijft het totaal correct, zelfs bij zware parallelle klikken.
Wil je minder racecondities, begin dan in de database. App-code kan retryen, time-outs krijgen of twee keer draaien. Een database-constraint is de laatste poortwachter die correct blijft, zelfs als twee requests tegelijk binnenkomen.
Unieke constraints stoppen duplicaten die "nooit zouden mogen gebeuren" maar toch gebeuren: e-mailadressen, ordernummers, factuurnummers of een "één actieve subscription per gebruiker" regel. Als twee aanmeldingen tegelijk binnenkomen, accepteert de database één rij en wijst de andere af.
Foreign keys voorkomen kapotte referenties. Zonder hen kan de één een parent verwijderen terwijl de ander een child aanmaakt dat naar niets verwijst, wat orphan rows oplevert die moeilijk schoon te maken zijn.
Check constraints houden waarden in een veilig bereik en dwingen eenvoudige statusregels af. Bijvoorbeeld: quantity >= 0, rating tussen 1 en 5, of status beperkt tot een toegestane set.
Behandel constraint-fouten als verwachte uitkomsten, niet als "server errors." Vang unique-, foreign key- en check-violations af, geef een duidelijke melding zoals "Dat e-mailadres is al in gebruik" en log details voor debugging zonder interne info te lekken.
Voorbeeld: twee mensen klikken tijdens vertraging op "Maak bestelling aan". Met een unieke constraint op (user_id, cart_id) krijg je geen twee bestellingen. Je krijgt één bestelling en één nette, verklaarbare afwijzing.
Sommige writes zijn geen enkel statement. Je leest een rij, controleert een regel, update een status en misschien voeg je een audit-log toe. Als twee requests dat tegelijk doen, kunnen ze allebei door de check komen en beide schrijven. Dat is het klassieke faalpatroon.
Wikkel de meerstapswrite in één database-transactie zodat alle stappen samen slagen of geen. Belangrijker: de transactie geeft je een plek om te controleren wie tegelijk dezelfde data mag veranderen.
Wanneer maar één actor een record tegelijk mag bewerken, gebruik een rij-level lock. Bijvoorbeeld: lock de orderrij, bevestig dat deze nog in "pending" staat, draai hem dan naar "approved" en schrijf de audit entry. De tweede request wacht dan, checkt opnieuw en stopt.
Kies op basis van hoe vaak botsingen voorkomen:
Houd lock-tijd kort. Doe zo weinig mogelijk werk terwijl je de lock houdt: geen externe API-calls, geen trage file-werk, geen grote loops. Als je flows bouwt in een tool zoals Koder.ai, houd de transactie alleen rond de database-stappen, en doe de rest na commit.
Kies één flow die geld of vertrouwen kan verliezen als hij botst. Een veelvoorkomend voorbeeld: maak een order, reserveer voorraad, zet daarna de orderstatus op bevestigd.
Schrijf precies op welke stappen je code vandaag neemt, in volgorde. Wees specifiek over wat gelezen wordt, wat geschreven wordt en wat "succes" betekent. Botsingen verbergen zich in de kloof tussen een read en een latere write.
Een hardening-pad dat in de meeste stacks werkt:
Voeg één test toe die de fix bewijst. Draai twee requests tegelijk tegen hetzelfde product en dezelfde hoeveelheid. Assert dat precies één order bevestigd wordt en de ander gecontroleerd faalt (geen negatieve voorraad, geen dubbele reserveringsrijen).
Als je snel apps genereert (ook met platforms zoals Koder.ai) is deze checklist nog steeds de moeite waard op de paar write-paths die echt tellen.
Een van de grootste oorzaken is vertrouwen op de UI. Disabled buttons en client-side checks helpen, maar gebruikers kunnen dubbelklikken, verversen, twee tabs openen of een request opnieuw afspelen vanuit een flaky verbinding. Als de server niet idempotent is, glippen duplicaten door.
Een andere stille bug: je vangt een databasefout (zoals een unique constraint violation) maar zet de workflow toch voort. Dat resulteert vaak in "create mislukte, maar we stuurden toch de e-mail" of "betaling mislukte, maar we markeerden de order als betaald." Zodra side-effects plaatsvinden is het lastig terug te draaien.
Lange transacties zijn ook een valkuil. Als je een transactie openhoudt terwijl je e-mail, betalingen of third-party APIs aanroept, houd je locks langer vast dan nodig. Dat verhoogt wachten, timeouts en de kans dat requests elkaar blokkeren.
Het mixen van background jobs en gebruikersacties zonder één enkele bron van waarheid creëert split-brain. Een job retryt en update een rij terwijl een gebruiker hem aan het bewerken is, en nu denkt elk proces dat het de laatste schrijver was.
Een paar "oplossingen" die het echte probleem niet verhelpen:
Als je bouwt met een chat-to-app tool zoals Koder.ai, gelden dezelfde regels: vraag om server-side constraints en duidelijke transactionele grenzen, niet alleen mooiere UI-guards.
Racecondities verschijnen vaak alleen onder echte traffic. Een korte pre-release ronde kan de meest voorkomende botsingspunten vangen zonder grote herbouw.
Begin met de database. Als iets uniek moet zijn (e-mails, factuurnummers, één actieve subscription per gebruiker), maak er een echte unique constraint van, geen app-level "we checken eerst" regel. Zorg er dan voor dat je code verwacht dat de constraint soms faalt en geef een duidelijke, veilige respons.
Kijk daarna naar state. Elke statuswijziging (Concept -> Ingediend -> Goedgekeurd) moet gevalideerd worden tegen een expliciete set toegestane overgangen. Als twee requests hetzelfde record proberen te verplaatsen, moet de tweede ofwel worden afgewezen of een no-op worden, niet een tussenstaat creëren.
Een praktische pre-release checklist:
Als je flows bouwt in Koder.ai, behandel dit als acceptatiecriteria: de gegenereerde app moet veilig falen onder repeats en concurrency, niet alleen de happy path doorstaan.
Twee medewerkers openen hetzelfde inkoopverzoek. Beiden klikken binnen enkele seconden op Goedkeuren. Beide requests bereiken de server.
Wat er mis kan gaan is rommelig: het verzoek wordt twee keer "goedgekeurd", er gaan twee notificaties uit, en totalen die aan goedkeuringen hangen (gebruikt budget, dagelijkse goedkeuringsteller) kunnen met 2 stijgen. Beide updates zijn op zichzelf geldig, maar ze botsen.
Hier is een plan van aanpak dat goed werkt met een PostgreSQL-achtige database.
Voeg een regel toe die garandeert dat er maar één goedkeuringsrecord kan bestaan voor een verzoek. Bijvoorbeeld: sla goedkeuringen op in een aparte tabel en dwing een unieke constraint op request_id af. Nu faalt de tweede insert zelfs als de app-code een bug heeft.
Bij goedkeuren doe je de hele transitie in één transactie:
Als de tweede medewerker te laat komt, ziet die ofwel 0 rijen geüpdatet of een unique-constraint error. Hoe dan ook, slechts één wijziging wint.
Na de fix ziet de eerste medewerker Approved en krijgt de normale bevestiging. De tweede medewerker ziet een vriendelijke melding zoals: "Dit verzoek is al door iemand anders goedgekeurd. Ververs om de laatste status te zien." Geen spinnen, geen dubbele notificaties, geen stille fouten.
Als je een CRUD-flow genereert in een platform als Koder.ai (Go-backend met PostgreSQL), kun je deze checks in de approve-actie één keer opnemen en het patroon hergebruiken voor andere "één winnaar"-acties.
Racecondities zijn het makkelijkst te verhelpen als je ze als een herhaalbare routine behandelt, niet als een eenmalige bughunt. Focus op de paar write-paths die het meest tellen en maak die saai juist voordat je iets anders verfijnt.
Begin met het benoemen van je top collision points. In veel CRUD-apps is het dezelfde drie-eenheid: tellers (likes, voorraad, saldi), statuswijzigingen (Concept -> Ingediend -> Goedgekeurd) en dubbele submits (dubbelklikken, retries, trage netwerken).
Een routine die standhoudt:
Als je op Koder.ai bouwt, is Planning Mode een praktische plek om elke write-flow als stappen en regels uit te tekenen voordat je code genereert in Go en PostgreSQL. Snapshots en rollback zijn ook handig als je nieuwe constraints of lock-gedrag uitrolt en snel terug wilt kunnen bij een edge-case.
Na verloop van tijd wordt dit een gewoonte: elke nieuwe write-feature krijgt een constraint, een transactiestrategie en een concurrency-test. Zo houden racecondities in CRUD-apps op verrassingen te zijn.