Duplicaten in CRUD-apps voorkom je met meerdere lagen: database-unique constraints, idempotency-sleutels en UI-states die dubbele submits blokkeren.

Een duplicaatrecord is wanneer je app hetzelfde ding twee keer opslaat. Dat kan twee bestellingen voor dezelfde checkout zijn, twee supporttickets met dezelfde gegevens, of twee accounts die vanuit dezelfde aanmeldstroom zijn aangemaakt. In een CRUD-app zien duplicaten er meestal uit als normale rijen, maar ze zijn verkeerd als je naar de data als geheel kijkt.
De meeste duplicaten beginnen met normaal gedrag. Iemand klikt twee keer op Create omdat de pagina traag aanvoelt. Op mobiel is een dubbele tik makkelijk te missen. Zelfs zorgvuldige gebruikers proberen het opnieuw als de knop nog actief lijkt en er geen duidelijke indicatie is dat er iets gebeurt.
Dan heb je het rommelige midden: netwerken en servers. Een verzoek kan time-outs krijgen en automatisch opnieuw worden verzonden. Een clientbibliotheek kan een POST herhalen als die denkt dat de eerste poging is mislukt. De eerste aanvraag kan slagen, maar de respons raakt zoek, dus probeert de gebruiker het nogmaals en maakt een tweede kopie.
Je kunt dit niet met maar één laag oplossen omdat elke laag slechts een deel van het verhaal ziet. De UI kan onbedoelde dubbele submits verminderen, maar kan retries door slechte verbindingen niet tegenhouden. De server kan herhalingen detecteren, maar heeft een betrouwbare manier nodig om te herkennen “dit is dezelfde create opnieuw”. De database kan regels afdwingen, maar alleen als je definieert wat “hetzelfde ding” betekent.
Het doel is simpel: maak creates veilig, zelfs als hetzelfde verzoek twee keer gebeurt. De tweede poging moet een no-op worden, een nette “al aangemaakt”-respons, of een gecontroleerd conflict — geen tweede rij.
Veel teams behandelen duplicaten als een databaseprobleem. In de praktijk ontstaan duplicaten meestal eerder, wanneer dezelfde create-actie meer dan eens wordt aangeroepen.
Een gebruiker klikt op Create en er lijkt niets te gebeuren, dus klikt hij opnieuw. Of hij drukt op Enter en klikt meteen daarna op de knop. Op mobiel kun je twee snelle taps krijgen, overlappende touch- en click-events, of een gebaar dat twee keer registreert.
Zelfs als de gebruiker maar één keer indient, kan het netwerk het verzoek herhalen. Een timeout kan een retry triggeren. Een offline app kan een “Save” in de wachtrij zetten en opnieuw sturen bij reconnect. Sommige HTTP-libraries retryen automatisch bij bepaalde fouten, en je merkt het pas als je duplicaatrijen ziet.
Servers herhalen werk opzettelijk. Job-queues retryen falende jobs. Webhook-providers leveren vaak hetzelfde event meer dan eens, vooral als je endpoint traag is of een non-2xx status teruggeeft. Als je create-logica door die events wordt getriggerd, ga ervan uit dat duplicaten zullen gebeuren.
Concurrency creëert de meest slinkse duplicaten. Twee tabs sturen hetzelfde formulier binnen milliseconden. Als je server doet “bestaat het?” en dan insert, kunnen beide requests de check passeren voordat een van beide de insert uitvoert.
Beschouw client, netwerk en server als afzonderlijke bronnen van herhalingen. Je hebt verdedigingen in alle drie nodig.
Als je één betrouwbare plek wilt om duplicaten te stoppen, zet de regel in de database. UI-fixes en serverchecks helpen, maar ze kunnen falen bij retries, vertragingen of twee gebruikers die tegelijk handelen. Een database-unique constraint is de definitieve autoriteit.
Begin met het kiezen van een real-world uniekheidsregel die overeenkomt met hoe mensen over het record denken. Veelvoorkomende voorbeelden:
Wees voorzichtig met velden die er uniek uitzien maar dat niet zijn, zoals een volledige naam.
Als je de regel hebt, dwing die af met een unique constraint (of unique index). Dat zorgt dat de database een tweede insert die de regel zou schenden afwijst, zelfs als twee verzoeken op hetzelfde moment aankomen.
Wanneer de constraint afgaat, bepaal wat de gebruiker moet ervaren. Als het altijd fout is om een duplicaat te maken, blokkeer het met een duidelijke melding ("Dat e-mailadres is al in gebruik"). Als retries vaak voorkomen en het record al bestaat, is het vaak beter om de retry als succes te behandelen en het bestaande record terug te geven ("Je bestelling is al aangemaakt").
Als jouw create eigenlijk "maak of hergebruik" is, kan een upsert het schoonste patroon zijn. Voorbeeld: “create customer by email” kan een nieuwe rij invoegen of de bestaande teruggeven. Gebruik dit alleen als het bij de zakelijke betekenis past. Als licht verschillende payloads voor dezelfde sleutel kunnen binnenkomen, besluit welke velden mogen updaten en welke onveranderd moeten blijven.
Unieke constraints vervangen idempotency keys of goede UI-states niet, maar ze geven je een harde stop waarop alles anders kan steunen.
Een idempotency-sleutel is een unieke token die één gebruikersintentie vertegenwoordigt, zoals “maak deze bestelling één keer”. Als hetzelfde verzoek opnieuw wordt verzonden (dubbele klik, netwerkretry, mobiele resume), behandelt de server het als een retry, niet als een nieuwe create.
Dit is één van de meest praktische hulpmiddelen om create-endpoints veilig te maken wanneer de client niet kan zien of de eerste poging is geslaagd.
Endpoints die het meest profiteren zijn die waar een duplicaat kostbaar of verwarrend is, zoals orders, facturen, betalingen, uitnodigingen, abonnementen en formulieren die e-mails of webhooks triggeren.
Bij een retry moet de server het oorspronkelijke resultaat van de eerste succesvolle poging teruggeven, inclusief hetzelfde record-ID en dezelfde statuscode. Om dat te doen, sla je een klein idempotency-record op, gekeyed op (gebruiker of account) + endpoint + idempotency-key. Sla zowel de uitkomst (record-ID, response body) als een “in progress”-status op zodat twee bijna gelijktijdige requests niet twee rijen aanmaken.
Bewaar idempotency-records lang genoeg om reële retries te dekken. Een veelgebruikte baseline is 24 uur. Voor betalingen bewaren veel teams 48–72 uur. Een TTL houdt opslag beperkt en past bij hoe lang een retry waarschijnlijk is.
Als je API's genereert met een chatgestuurde builder zoals Koder.ai, wil je idempotentie toch expliciet maken: accepteer een client-gestuurde sleutel (header of veld) en handhaaf “dezelfde sleutel, hetzelfde resultaat” op de server.
Idempotentie maakt een create-verzoek veilig om te herhalen. Als de client opnieuw probeert vanwege een timeout (of de gebruiker klikt twee keer), geeft de server hetzelfde resultaat terug in plaats van een tweede rij te maken.
Idempotency-Key), maar hem in de JSON-body meesturen kan ook.Het belangrijke detail is dat “check + store” veilig moet zijn onder concurrency. In de praktijk sla je het idempotency-record op met een unique constraint op (scope, key) en behandel je conflicts als een signaal om te hergebruiken.
if idempotency_record exists for (user_id, key):
return saved_response
else:
begin transaction
create the row
save response under (user_id, key)
commit
return response
Voorbeeld: een klant klikt “Create invoice”, de app stuurt key abc123, en de server maakt invoice inv_1007. Als de telefoon het signaal verliest en opnieuw probeert, antwoordt de server met hetzelfde inv_1007-antwoord, niet inv_1008.
Als je test, stop dan niet bij “dubbele klik”. Simuleer een verzoek dat op de client timeout, maar dat op de server toch compleet wordt afgewikkeld, en probeer daarna opnieuw met dezelfde sleutel.
Server-side verdedigingen zijn belangrijk, maar veel duplicaten beginnen bij een mens die normaal twee keer doet. Een goede UI maakt het veilige pad duidelijk.
Schakel de submitknop uit zodra de gebruiker indient. Doe het bij de eerste klik, niet na validatie of nadat het verzoek is gestart. Als het formulier via meerdere controls kan worden ingediend (een knop en Enter), lock dan de hele formulierstatus, niet alleen één knop.
Toon een duidelijke voortgangsstatus die één vraag beantwoordt: werkt het? Een simpele “Opslaan...” label of spinner is genoeg. Houd de layout stabiel zodat de knop niet gaat schuiven en een tweede klik uitlokt.
Een kleine set regels voorkomt de meeste dubbele submits: zet aan het begin van de submithandler een isSubmitting-flag, negeer nieuwe submits zolang die true is (voor klik en Enter), en maak hem pas weer false als je een echte response hebt.
Trage responses zijn waar veel apps struikelen. Als je de knop opnieuw inschakelt op een vaste timer (bijvoorbeeld na 2 seconden), kunnen gebruikers opnieuw indienen terwijl de eerste aanvraag nog loopt. Schakel pas opnieuw in wanneer de poging compleet is.
Na succes, maak herindiening onwaarschijnlijk. Navigeer weg (naar de nieuwe recordpagina of lijst) of toon een duidelijke successtatus met het aangemaakte record zichtbaar. Vermijd het achterlaten van hetzelfde ingevulde formulier met de knop ingeschakeld.
De hardnekkige duplicate-bugs komen van alledaags “vreemd maar veelvoorkomend” gedrag: twee tabs, een refresh, of een telefoon die signaal verliest.
Bepaal eerst de juiste scope van uniekheid. “Uniek” betekent zelden “uniek in de hele database.” Het kan één per gebruiker zijn, één per workspace of één per tenant. Als je sync met een extern systeem hebt, heb je mogelijk uniekheid per externe bron plus zijn externe ID nodig. Een veilige aanpak is om de exacte zin op te schrijven die je bedoelt (bijvoorbeeld: “Één factuurnummer per tenant per jaar”) en die dan af te dwingen.
Multi-tab gedrag is een klassieke valkuil. UI-loading states helpen in één tab, maar ze doen niets over tabs heen. Hier moeten server-side verdedigingen nog steeds standhouden.
Terug knop en refresh kunnen onbedoelde resubmits triggeren. Na een succesvolle create verifiëren gebruikers vaak door te refreshen of drukken Back en sturen het formulier dat nog chỉnhbaar lijkt opnieuw. Geef bij voorkeur een view van het aangemaakte object in plaats van het originele formulier en laat de server veilige replays afhandelen.
Mobiel voegt onderbrekingen toe: backgrounding, onbetrouwbare netwerken en automatische retries. Een verzoek kan geslaagd zijn, maar de app ontvangt nooit de response, dus probeert hij opnieuw bij resume.
De meest voorkomende fout is de UI als enige vangrail zien. Een uitgeschakelde knop en spinner helpen, maar ze dekken geen refreshes, onbetrouwbare mobiele netwerken, gebruikers die een tweede tab openen of een client-bug. De server en database moeten nog steeds kunnen zeggen “deze create is al gebeurd”.
Een andere valkuil is het kiezen van het verkeerde veld voor uniekheid. Als je een unique constraint zet op iets dat niet echt uniek is (een achternaam, een afgeronde timestamp, een vrije-tekst titel), blokkeer je geldige records. Gebruik in plaats daarvan een echt identificerend veld (zoals een externe provider-ID) of een gescopeerde regel (uniek per gebruiker, per dag of per ouderrecord).
Idempotency-keys zijn ook makkelijk slecht te implementeren. Als de client bij elke retry een gloednieuwe key genereert, krijg je bij elke poging een nieuw create. Houd dezelfde key voor de hele gebruikersintentie, van de eerste klik tot alle retries.
Let ook op wat je teruggeeft bij retries. Als het eerste verzoek het record maakte, moet een retry hetzelfde resultaat teruggeven (of in elk geval hetzelfde record-ID), niet een vage fout die gebruikers opnieuw laat proberen.
Als een unique constraint een duplicaat blokkeert, verberg dat dan niet achter “Er is iets misgegaan.” Zeg wat er gebeurde in gewone taal: “Dit factuurnummer bestaat al. We hebben het origineel behouden en hebben geen tweede aangemaakt.”
Voordat je uitrolt, doe een korte check speciaal voor paden die records aanmaken. De beste resultaten krijg je door verdedigingen te stapelen zodat een gemiste klik, retry of langzaam netwerk geen twee rijen kan maken.
Bevestig drie dingen:
Een praktische gevoelstest: open het formulier, klik snel twee keer op submit, refresh middenin het verzenden en probeer opnieuw. Als je twee records kunt maken, zullen echte gebruikers dat ook doen.
Stel je een kleine facturatie-app voor. Een gebruiker vult een nieuwe factuur in en tikt op Create. Het netwerk is traag, het scherm verandert niet meteen en ze tikken nogmaals op Create.
Met alleen UI-bescherming zou je de knop uitschakelen en een spinner tonen. Dat helpt, maar is niet genoeg. Een dubbele tik kan op sommige apparaten toch nog doorkomen, een retry kan optreden na een timeout, of de gebruiker kan vanuit twee tabs indienen.
Met alleen een database-unique constraint kun je exacte duplicaten stoppen, maar de ervaring kan ruw zijn. Het eerste verzoek slaagt, het tweede botst op de constraint en de gebruiker ziet een fout, ook al is de factuur al aangemaakt.
Het schone resultaat is idempotentie plus een unique constraint:
Een eenvoudige UI-tekst na de tweede tik: “Factuur aangemaakt — we hebben de dubbele inzending genegeerd en je eerste verzoek behouden.”
Als je de basis eenmaal hebt, zijn de volgende winstpunten zichtbaarheid, cleanup en consistentie.
Voeg lichte logging toe rond create-paden zodat je het verschil kunt zien tussen een echte gebruikersactie en een retry. Log de idempotency-key, de betrokken unieke velden en de uitkomst (aangemaakt vs bestaand teruggegeven vs geweigerd). Je hebt geen zware tooling nodig om te beginnen.
Als er al duplicaten bestaan, ruim ze dan op met een duidelijke regel en een audittrail. Bewaar bijvoorbeeld het oudste record als de “winner”, koppel gerelateerde rijen (betalingen, line items) opnieuw en markeer de andere als gemerged in plaats van ze te verwijderen. Dat maakt support en rapportage veel makkelijker.
Schrijf je uniekheids- en idempotentie-regels op één plek op: wat is uniek en in welke scope, hoe lang idempotency-keys leven, hoe fouten eruitzien en wat de UI moet doen bij retries. Dat voorkomt dat nieuwe endpoints stilletjes de veiligheidsrails omzeilen.
Als je CRUD-schermen snel bouwt in Koder.ai (koder.ai), is het de moeite waard deze gedragspatronen onderdeel te maken van je standaardtemplate: unique constraints in het schema, idempotente create-endpoints in de API en duidelijke loading-states in de UI. Zo gaat snelheid niet ten koste van rommelige data.
Een duplicaatrecord is wanneer hetzelfde echte object twee keer wordt opgeslagen, zoals twee bestellingen voor één checkout of twee tickets voor hetzelfde probleem. Het ontstaat meestal doordat dezelfde “create”-actie meer dan eens wordt uitgevoerd door een dubbele submit van de gebruiker, retries of gelijktijdige verzoeken.
Omdat een tweede create kan worden veroorzaakt zonder dat de gebruiker het merkt: een dubbele tik op mobiel, Enter indrukken en daarna op de knop klikken, enz. Zelfs als een gebruiker één keer indient, kunnen client, netwerk of server het verzoek opnieuw proberen na een timeout; je kunt niet zomaar aannemen dat “POST betekent één keer”.
Niet betrouwbaar. Het uitschakelen van de knop en het tonen van “Opslaan…” vermindert per ongeluk dubbele submits, maar stopt geen retries door onbetrouwbare netwerken, refreshes, meerdere tabs, achtergrondworkers of webhook-herleveringen. Je hebt ook server- en databaseverdediging nodig.
Een unieke constraint is de laatste verdedigingslinie die twee rijen tegenhoudt als twee inserts tegelijkertijd binnenkomen. Hij werkt het beste als je een realistische uniekheidsregel definieert (vaak gescopeerd, bijvoorbeeld per tenant of workspace) en die direct in de database afdwingt.
Ze lossen verschillende problemen op. Unieke constraints blokkeren duplicaten op basis van velden (zoals een factuurnummer), terwijl idempotentie-sleutels een specifieke create-poging herhaal-veilig maken (dezelfde key geeft hetzelfde resultaat). Door beide te gebruiken krijg je veiligheid én een betere gebruikerservaring bij retries.
Genereer één sleutel per gebruikersintentie (één druk op “Create”), hergebruik die voor retries van die intentie en stuur hem elke keer mee met het verzoek. De sleutel moet stabiel zijn bij timeouts en app-resumes, maar mag niet opnieuw gebruikt worden voor een andere create.
Bewaar een idempotentie-record dat keyed is op scope (zoals gebruiker of account), endpoint en de idempotency-key, en sla de response op die je bij de eerste succesvolle aanvraag hebt teruggegeven. Als dezelfde key opnieuw binnenkomt, retourneer dan die opgeslagen response met hetzelfde aangemaakte record-ID in plaats van een nieuwe rij te maken.
Gebruik een concurrerend-veilige “check + store”-aanpak, meestal door een unieke constraint op het idempotentie-record zelf af te dwingen (voor scope en key). Zo kunnen twee vrijwel gelijktijdige verzoeken niet allebei beweren dat zij de “eerste” zijn; één moet het opgeslagen resultaat hergebruiken.
Bewaar ze lang genoeg om realistische retries te dekken; een veelgebruikte standaard is ongeveer 24 uur, langer voor flows zoals betalingen waar retries later kunnen voorkomen. Voeg een TTL toe zodat opslag niet onbeperkt groeit en zorg dat de TTL past bij hoe lang een client realistisch kan retryen.
Behandel een duplicate create als een succesvolle retry wanneer het duidelijk om dezelfde intentie gaat en retourneer het originele record (zelfde ID) in plaats van een vage fout. Als het object echt uniek moet zijn (bijv. een e-mail), geef dan een duidelijke conflictmelding die uitlegt wat er al bestaat en wat er daarna gebeurde.