Les conditions de concurrence dans les applications CRUD peuvent provoquer des commandes en double et des totaux erronés. Découvrez les points de collision fréquents et des solutions pratiques : contraintes, verrous et protections côté UX.

Une condition de concurrence se produit lorsque deux (ou plusieurs) requêtes mettent à jour les mêmes données presque en même temps, et que le résultat final dépend du moment où elles arrivent. Chaque requête a l'air correcte prise séparément. Ensemble, elles produisent un mauvais résultat.
Un exemple simple : deux personnes cliquent sur Enregistrer sur la même fiche client en l'espace d'une seconde. L'une met à jour l'email, l'autre le numéro de téléphone. Si les deux requêtes envoient l'enregistrement complet, la deuxième écriture peut écraser la première, et une modification disparaît sans erreur.
On observe ça plus souvent dans les applications rapides parce que les utilisateurs peuvent déclencher plus d'actions par minute. Ça augmente aussi pendant les moments chargés : ventes flash, clôtures de fin de mois, une grosse campagne d'emailing, ou chaque fois qu'une pile de requêtes cible les mêmes lignes.
Les utilisateurs signalent rarement « une condition de concurrence ». Ils signalent des symptômes : commandes ou commentaires en double, mises à jour manquantes ("J'ai sauvegardé, mais c'est revenu en arrière"), totaux étranges (stock négatif, compteurs qui reculent), ou statuts qui basculent de façon inattendue (approuvé, puis de nouveau en attente).
Les réessais aggravent le problème. Les gens double-cliquent, rafraîchissent après une réponse lente, soumettent depuis deux onglets, ou ont des réseaux instables qui poussent le navigateur ou l'app à renvoyer la requête. Si le serveur traite chaque requête comme une écriture neuve, vous pouvez obtenir deux créations, deux paiements, ou deux changements de statut qui ne devaient arriver qu'une fois.
La plupart des applications CRUD semblent simples : lire une ligne, changer un champ, sauvegarder. Le piège, c'est que votre application ne contrôle pas le timing. La base de données, le réseau, les réessais, le travail en arrière-plan et le comportement des utilisateurs se chevauchent.
Un déclencheur fréquent est que deux personnes éditent la même fiche. Les deux chargent les mêmes valeurs « courantes », font des modifications valides, et la dernière sauvegarde écrase silencieusement la première. Personne n'a fait d'erreur, mais une mise à jour est perdue.
Ça arrive aussi avec une seule personne. Un double-clic sur un bouton Enregistrer, taper en arrière puis avant, ou une connexion lente qui pousse à appuyer à nouveau sur Envoyer peut envoyer la même écriture deux fois. Si le point d'API n'est pas idempotent, vous pouvez créer des doublons, facturer deux fois, ou avancer un statut de deux pas.
Les usages modernes ajoutent des recouvrements : plusieurs onglets ou appareils connectés au même compte peuvent faire des mises à jour conflictuelles. Des jobs en arrière-plan (emails, facturation, synchronisation, nettoyage) peuvent toucher les mêmes lignes que les requêtes web. Les réessais automatiques au niveau client, du load balancer ou du job runner peuvent répéter une requête qui a déjà réussi.
Si vous livrez des fonctionnalités rapidement, la même fiche est souvent mise à jour depuis plus d'endroits qu'on ne s'en souvient. Si vous utilisez un constructeur piloté par chat comme Koder.ai, l'application peut croître encore plus vite, donc il vaut mieux considérer la concurrence comme un comportement normal, pas comme un cas marginal.
Les conditions de concurrence apparaissent rarement dans des démos « créer un enregistrement ». Elles apparaissent là où deux requêtes touchent la même vérité au même instant. Connaître les points chauds habituels vous aide à concevoir des écritures sûres dès le départ.
Tout ce qui ressemble à "ajouter 1" peut casser sous charge : likes, compteurs de vues, totaux, numéros de facture, numéros de ticket. Le schéma risqué est lire la valeur, l'incrémenter, puis la réécrire. Deux requêtes peuvent lire la même valeur de départ et s'écraser mutuellement.
Des workflows comme Draft -> Submitted -> Approved -> Paid semblent simples, mais les collisions sont courantes. Le problème commence quand deux actions sont possibles en même temps (approuver et éditer, annuler et payer). Sans protections, vous pouvez obtenir un enregistrement qui saute des étapes, revient en arrière, ou affiche des états différents dans des tables différentes.
Traitez les changements d'état comme un contrat : autorisez seulement l'étape suivante valide et refusez le reste.
Places restantes, quantités en stock, créneaux de rendez-vous et champs "capacité restante" créent le problème classique de survente. Deux acheteurs finalisent au même moment, voient la disponibilité, et réussissent tous les deux. Si la base de données n'est pas le juge final, vous finirez par vendre plus que ce que vous avez.
Certaines règles sont absolues : un email par compte, un abonnement actif par utilisateur, un panier ouvert par utilisateur. Elles échouent souvent quand vous vérifiez d'abord ("est-ce que ça existe ?") puis insérez. Sous concurrence, les deux requêtes peuvent passer la vérification.
Si vous générez des flux CRUD rapidement (par exemple en discutant votre app avec Koder.ai), notez ces points chauds tôt et soutenez-les par des contraintes et des écritures sûres, pas seulement des vérifications côté UI.
Beaucoup de conditions de concurrence commencent par quelque chose d'ennuyeux : la même action est envoyée deux fois. Les utilisateurs double-cliquent. Le réseau est lent, donc ils cliquent de nouveau. Un téléphone enregistre deux tapes. Parfois ce n'est pas intentionnel : la page se rafraîchit après un POST et le navigateur propose de renvoyer le formulaire.
Quand ça arrive, votre backend peut exécuter deux créations ou mises à jour en parallèle. Si les deux réussissent, vous obtenez des doublons, des totaux incorrects, ou un changement de statut appliqué deux fois (par exemple, approuver puis encore approuver). Ça paraît aléatoire parce que tout dépend du timing.
L'approche la plus sûre est la défense en profondeur. Corrigez l'UI, mais supposez que l'UI peut échouer.
Changements pratiques à appliquer à la plupart des flux d'écriture :
Exemple : un utilisateur appuie deux fois sur "Payer la facture" sur mobile. L'UI doit bloquer le second appui. Le serveur doit aussi rejeter la deuxième requête lorsqu'il voit la même clé d'idempotence, et renvoyer le résultat de succès original au lieu de facturer deux fois.
Les champs d'état semblent simples jusqu'à ce que deux choses essayent de les changer en même temps. Un utilisateur clique sur Approuver pendant qu'un job automatique marque le même enregistrement Expiré, ou deux membres de l'équipe travaillent le même élément dans des onglets différents. Les deux mises à jour peuvent réussir, mais le statut final dépend du timing, pas de vos règles.
Traitez l'état comme une petite machine à états. Gardez une table courte des mouvements autorisés (par exemple : Draft -> Submitted -> Approved, et Submitted -> Rejected). Ensuite, chaque écriture vérifie : "Ce mouvement est-il autorisé depuis le statut courant ?" Si non, rejetez plutôt que d'écraser silencieusement.
Le verrouillage optimiste vous aide à détecter les mises à jour obsolètes sans bloquer les autres utilisateurs. Ajoutez un numéro de version (ou updated_at) et exigez qu'il corresponde lors de la sauvegarde. Si quelqu'un d'autre a changé la ligne après que vous l'ayez chargée, votre mise à jour affectera zéro ligne et vous pouvez afficher un message clair comme "Cet élément a changé, rafraîchissez et réessayez."
Un schéma simple pour les mises à jour d'état :
Aussi, centralisez les changements d'état. Si les mises à jour sont dispersées entre écrans, jobs en arrière-plan et webhooks, vous manquerez une règle. Mettez-les derrière une seule fonction ou un seul endpoint qui applique les mêmes vérifications à chaque fois.
Le bug de compteur le plus courant paraît inoffensif : l'app lit une valeur, ajoute 1, puis la réécrit. Sous charge, deux requêtes peuvent lire le même nombre et écrire la même nouvelle valeur, si bien qu'un incrément est perdu. C'est facile à manquer parce que ça "fonctionne généralement" en test.
Si une valeur est juste incrémentée ou décrémentée, laissez la base de données le faire en une seule instruction. La base garantira la sécurité même quand de multiples requêtes frappent en même temps.
UPDATE posts
SET like_count = like_count + 1
WHERE id = $1;
La même idée s'applique à l'inventaire, aux compteurs de vues, aux compteurs de réessai, et à tout ce qui s'exprime comme "new = old + delta".
Les totaux se trompent souvent quand vous stockez un nombre dérivé (order_total, account_balance, project_hours) puis le mettez à jour depuis plusieurs endroits. Si vous pouvez calculer le total à partir des lignes sources (positions de commande, écritures de journal), vous évitez toute une classe de bugs de dérive.
Quand il faut garder un total pour des raisons de performance, traitez-le comme une écriture critique. Gardez les mises à jour des lignes sources et du total stocké dans la même transaction. Assurez-vous qu'un seul écrivain puisse modifier ce total à la fois (verrouillage, mises à jour gardées, ou chemin de propriétaire unique). Ajoutez des contraintes qui empêchent des valeurs impossibles (par exemple, inventaire négatif). Puis réconciliez occasionnellement avec un job en arrière-plan qui recalcule et signale les écarts.
Un exemple concret : deux utilisateurs ajoutent des articles au même panier en même temps. Si chaque requête lit cart_total, ajoute le prix de son article, puis le réécrit, une addition peut disparaître. Si vous mettez à jour les lignes du panier et le total du panier ensemble dans une transaction, le total reste correct même sous clics parallèles intensifs.
Si vous voulez moins de conditions de concurrence, commencez par la base de données. Le code applicatif peut réessayer, expirer ou s'exécuter deux fois. Une contrainte en base est la porte finale qui reste correcte même lorsque deux requêtes frappent en même temps.
Les contraintes d'unicité empêchent les doublons qui "ne devraient jamais arriver" mais arrivent : adresses email, numéros de commande, identifiants de facture, ou une règle "un abonnement actif par utilisateur". Quand deux inscriptions arrivent simultanément, la base accepte une ligne et rejette l'autre.
Les clés étrangères empêchent les références cassées. Sans elles, une requête peut supprimer une ligne parent pendant qu'une autre crée une ligne enfant qui pointe vers rien, laissant des orphelins difficiles à nettoyer.
Les contraintes CHECK maintiennent les valeurs dans un intervalle sûr et imposent des règles d'état simples. Par exemple, quantity >= 0, rating entre 1 et 5, ou status limité à un ensemble autorisé.
Traitez les violations de contrainte comme des résultats attendus, pas comme des "erreurs serveur". Capturez les violations d'unicité, de clé étrangère et de check, renvoyez un message clair comme "Cet email est déjà utilisé", et consignez les détails pour le débogage sans divulguer d'internes.
Exemple : deux personnes cliquent sur "Créer commande" pendant un délai. Avec une contrainte unique sur (user_id, cart_id), vous n'avez pas deux commandes. Vous avez une commande et un rejet propre et explicable.
Certaines écritures ne sont pas une seule instruction. Vous lisez une ligne, vérifiez une règle, mettez à jour un statut, et peut-être insérez un log d'audit. Si deux requêtes font cela en même temps, elles peuvent toutes deux passer la vérification et écrire. C'est le schéma classique de défaillance.
Encapsulez l'écriture multi-étapes dans une transaction afin que toutes les étapes réussissent ensemble ou échouent ensemble. Plus important, la transaction vous donne un endroit pour contrôler qui est autorisé à changer les mêmes données en même temps.
Quand un seul acteur peut éditer une ligne à la fois, utilisez un verrou de ligne. Par exemple : verrouillez la ligne de commande, confirmez qu'elle est toujours en "pending", puis passez-la en "approved" et écrivez l'entrée d'audit. La seconde requête attendra, puis reverra l'état et s'arrêtera.
Choisissez selon la fréquence des collisions :
Gardez le temps de verrouillage court. Faites le moins de travail possible pendant que vous le détenez : pas d'appels API externes, pas de travail de fichier lent, pas de grandes boucles. Si vous construisez des flows dans un outil comme Koder.ai, maintenez la transaction juste pour les étapes en base, puis faites le reste après le commit.
Choisissez un flux qui peut coûter de l'argent ou de la confiance si une collision survient. Un flux courant est : créer une commande, réserver du stock, puis définir le statut de la commande sur confirmé.
Écrivez les étapes exactes que votre code effectue aujourd'hui, dans l'ordre. Soyez précis sur ce qui est lu, ce qui est écrit, et ce que signifie "succès". Les collisions se cachent dans le vide entre une lecture et une écriture ultérieure.
Un chemin de renforcement qui marche dans la plupart des stacks :
Ajoutez un test qui prouve la correction. Lancez deux requêtes en même temps contre le même produit et la même quantité. Vérifiez qu'une seule commande est confirmée, et que l'autre échoue de manière contrôlée (pas de stock négatif, pas de lignes de réservation en double).
Si vous générez des apps rapidement (y compris avec des plateformes comme Koder.ai), cette checklist vaut la peine pour les quelques chemins d'écriture qui comptent le plus.
Une des causes majeures est de faire confiance à l'UI. Les boutons désactivés et les vérifications côté client aident, mais les utilisateurs peuvent double-cliquer, rafraîchir, ouvrir deux onglets ou rejouer une requête depuis une connexion instable. Si le serveur n'est pas idempotent, des doublons passent.
Un autre bug discret : vous attrapez une erreur de base (comme une violation d'unicité) mais vous continuez le flux quand même. Cela devient souvent "création échouée, mais on a quand même envoyé l'email" ou "paiement échoué, mais on a marqué la commande comme payée". Une fois que des effets secondaires sont déclenchés, c'est difficile à annuler.
Les transactions longues sont aussi un piège. Si vous gardez une transaction ouverte pendant que vous appelez un service d'email, un paiement ou une API tierce, vous conservez des verrous plus longtemps que nécessaire. Ça augmente les attentes, les timeouts et les blocages entre requêtes.
Mélanger jobs en arrière-plan et actions utilisateur sans une source de vérité unique crée un état en split-brain. Un job réessaie et met à jour une ligne pendant qu'un utilisateur l'édite, et maintenant les deux pensent être le dernier écrivain.
Quelques "solutions" qui ne règlent pas réellement le problème :
Si vous construisez avec un outil chat-to-app comme Koder.ai, les mêmes règles s'appliquent : demandez des contraintes côté serveur et des limites transactionnelles claires, pas seulement de meilleures protections UI.
Les conditions de concurrence apparaissent souvent seulement sous vraie charge. Un passage pré-livraison peut détecter les points de collision les plus courants sans réécriture complète.
Commencez par la base de données. Si quelque chose doit être unique (emails, numéros de facture, un abonnement actif par utilisateur), faites-en une vraie contrainte unique, pas une règle côté application "on vérifie d'abord". Ensuite assurez-vous que votre code s'attend à ce que la contrainte échoue parfois и renvoie une réponse claire et sûre.
Ensuite, regardez l'état. Tout changement d'état (Draft -> Submitted -> Approved) doit être validé par un ensemble explicite de transitions autorisées. Si deux requêtes essaient de déplacer la même ligne, la seconde doit être rejetée ou devenir un no-op, pas créer un état intermédiaire.
Une checklist pratique avant release :
Si vous construisez des flows dans Koder.ai, prenez ces points comme critères d'acceptation : l'app générée doit échouer proprement sous réessais et en concurrence, pas seulement réussir le happy path.
Deux membres du personnel ouvrent la même demande d'achat. Les deux cliquent sur Approuver en quelques secondes. Les deux requêtes atteignent le serveur.
Ce qui peut mal tourner est confus : la demande est "approuvée" deux fois, deux notifications partent, et tout total lié aux approbations (budget utilisé, compte quotidien d'approbations) peut augmenter de 2. Les deux mises à jour sont valides séparément, mais elles entrent en collision.
Voici un plan de correction qui fonctionne bien avec une base de données de type PostgreSQL.
Ajoutez une règle qui garantit qu'une seule ligne d'approbation peut exister pour une demande. Par exemple, stockez les approbations dans une table séparée et imposez une contrainte UNIQUE sur request_id. Ainsi, le second insert échoue même si le code applicatif comporte un bug.
Lors de l'approbation, faites toute la transition dans une seule transaction :
Si le second membre arrive en retard, il voit soit 0 lignes mises à jour, soit une erreur de contrainte d'unicité. Dans tous les cas, une seule modification l'emporte.
Après la correction, le premier utilisateur voit Approved et reçoit la confirmation normale. Le second voit un message convivial du type : "Cette demande a déjà été approuvée par quelqu'un d'autre. Rafraîchissez pour voir le statut le plus récent." Pas de notifications en double, pas d'échecs silencieux.
Si vous générez un flux CRUD sur une plateforme comme Koder.ai (backend Go avec PostgreSQL), vous pouvez intégrer ces vérifications dans l'action d'approbation une fois et réutiliser le modèle pour d'autres actions du type "un seul gagnant".
Les conditions de concurrence sont plus faciles à corriger lorsque vous les traitez comme une routine répétable, pas comme une chasse au bug ponctuelle. Concentrez-vous sur les quelques chemins d'écriture qui importent, et rendez-les ennuyeusement corrects avant de polir le reste.
Commencez par nommer vos principaux points de collision. Dans beaucoup d'apps CRUD, c'est le même trio : compteurs (likes, inventaire, soldes), changements d'état (Draft -> Submitted -> Approved), et doubles envois (double-clics, réessais, réseaux lents).
Une routine solide :
Si vous construisez sur Koder.ai, Planning Mode est un bon endroit pour cartographier chaque flux d'écriture en étapes et règles avant de générer le code Go et PostgreSQL. Les snapshots et le rollback sont aussi utiles quand vous déployez de nouvelles contraintes ou comportements de verrou et voulez un retour rapide en cas de cas limites.
Avec le temps, cela devient une habitude : chaque nouvelle fonctionnalité d'écriture obtient une contrainte, un plan transactionnel et un test de concurrence. C'est ainsi que les conditions de concurrence dans les applications CRUD cessent d'être des surprises.