Evitar registros duplicados en apps CRUD requiere varias capas: restricciones únicas en la base de datos, claves de idempotencia y estados de UI que eviten envíos dobles.

Un registro duplicado es cuando tu app guarda la misma entidad dos veces. Puede ser dos pedidos por el mismo checkout, dos tickets de soporte con los mismos datos o dos cuentas creadas desde el mismo flujo. En una app CRUD, los duplicados suelen parecer filas normales por separado, pero están mal cuando miras los datos en conjunto.
La mayoría de los duplicados empiezan con un comportamiento normal. Alguien pulsa Crear dos veces porque la página tarda. En móvil, un doble tap es fácil de pasar por alto. Incluso usuarios cuidadosos vuelven a intentarlo si el botón sigue activo y no hay una señal clara de que algo está pasando.
Luego está la parte intermedia y desordenada: redes y servidores. Una petición puede agotar el timeout y reintentarse automáticamente. Una librería cliente puede repetir un POST si cree que el primer intento falló. La primera petición puede tener éxito, pero la respuesta se pierde, así que el usuario lo vuelve a intentar y crea una copia segunda.
No puedes resolver esto en una sola capa porque cada capa ve solo parte de la historia. La UI puede reducir envíos dobles accidentales, pero no puede evitar reintentos por conexiones malas. El servidor puede detectar repeticiones, pero necesita una forma fiable de reconocer “esto es la misma creación de nuevo”. La base de datos puede imponer reglas, pero solo si defines qué significa “la misma cosa”.
El objetivo es simple: hacer que las creaciones sean seguras incluso cuando la misma petición sucede dos veces. El segundo intento debe convertirse en una operación sin efecto, en una respuesta limpia de “ya creado”, o en un conflicto controlado, no en una segunda fila.
Muchos equipos tratan los duplicados como un problema de base de datos. En la práctica, los duplicados suelen originarse antes, cuando la misma acción de crear se dispara más de una vez.
Un usuario pulsa Crear y parece que no pasa nada, así que pulsa de nuevo. O pulsa Enter y luego el botón. En móvil pueden darse dos taps rápidos, eventos de touch y click superpuestos, o un gesto que se registra dos veces.
Aunque el usuario solo envíe una vez, la red puede repetir la petición. Un timeout puede disparar un reintento. Una app offline puede encolar un “Guardar” y reenviarlo al reconectarse. Algunas librerías HTTP reintentan automáticamente ante ciertos errores, y no lo notarás hasta que veas filas duplicadas.
Los servidores repiten trabajo a propósito. Las colas de jobs reintentan trabajos fallidos. Los proveedores de webhook suelen entregar el mismo evento más de una vez, especialmente si tu endpoint está lento o devuelve un estado distinto de 2xx. Si tu lógica de creación se activa por estos eventos, asume que habrá duplicados.
La concurrencia crea los duplicados más sigilosos. Dos pestañas envían el mismo formulario en milisegundos. Si tu servidor hace “¿ya existe?” y luego inserta, ambas peticiones pueden pasar la comprobación antes de que ocurra la inserción.
Trata al cliente, la red y el servidor como fuentes separadas de repeticiones. Necesitarás defensas en las tres.
Si quieres un lugar fiable para detener duplicados, pon la regla en la base de datos. Las correcciones en la UI y las comprobaciones en servidor ayudan, pero pueden fallar con reintentos, latencia o dos usuarios actuando al mismo tiempo. Una restricción única en la base de datos es la autoridad final.
Empieza por elegir una regla de unicidad del mundo real que coincida con cómo la gente piensa sobre el registro. Ejemplos comunes:
Ten cuidado con campos que parecen únicos pero no lo son, como un nombre completo.
Cuando tengas la regla, hazla cumplir con una restricción única (o un índice único). Esto hace que la base de datos rechace una segunda inserción que violaría la regla, incluso si dos peticiones llegan en el mismo momento.
Cuando la restricción se dispare, decide qué debería experimentar el usuario. Si crear un duplicado es siempre incorrecto, bloquéalo con un mensaje claro ("Ese email ya está en uso"). Si los reintentos son comunes y el registro ya existe, suele ser mejor tratar el reintento como éxito y devolver el registro existente ("Tu pedido ya se creó").
Si tu creación es realmente “crear o reutilizar”, un upsert puede ser el patrón más limpio. Ejemplo: “crear cliente por email” puede insertar una fila nueva o devolver la existente. Usa esto solo cuando encaje con el significado del negocio. Si pueden llegar payloads ligeramente distintos para la misma clave, decide qué campos pueden actualizarse y cuáles deben permanecer inmutables.
Las restricciones únicas no reemplazan a las claves de idempotencia ni a buenos estados en la UI, pero te dan un tope duro sobre el que apoyarte.
Una clave de idempotencia es un token único que representa una intención del usuario, como “crear este pedido una vez”. Si la misma petición se envía de nuevo (doble clic, reintento de red, reanudación en móvil), el servidor la trata como un reintento, no como una nueva creación.
Esta es una de las herramientas más prácticas para asegurar endpoints de creación cuando el cliente no puede saber si el primer intento tuvo éxito.
Los endpoints que más se benefician son aquellos donde un duplicado es costoso o confuso: pedidos, facturas, pagos, invitaciones, suscripciones y formularios que disparan emails o webhooks.
En un reintento, el servidor debe devolver el resultado original del primer intento exitoso, incluyendo el mismo ID de registro y el mismo código de estado. Para ello, guarda un pequeño registro de idempotencia indexado por (usuario o cuenta) + endpoint + clave de idempotencia. Guarda tanto el resultado (ID del registro, cuerpo de la respuesta) como un estado “en progreso” para que dos peticiones casi simultáneas no creen dos filas.
Mantén los registros de idempotencia el tiempo suficiente para cubrir reintentos reales. Una base común es 24 horas. Para pagos, muchos equipos mantienen 48–72 horas. Un TTL mantiene el almacenamiento acotado y coincide con cuánto durará razonablemente un reintento.
Si generas APIs con un constructor asistido por chat como Koder.ai, aún conviene hacer la idempotencia explícita: acepta una clave enviada por el cliente (header o campo) y aplica la regla “misma clave, mismo resultado” en el servidor.
La idempotencia hace que una petición de creación sea segura de repetir. Si el cliente reintenta por un timeout (o un usuario hace doble clic), el servidor devuelve el mismo resultado en lugar de crear una segunda fila.
Idempotency-Key), pero enviarla en el cuerpo JSON también sirve.El detalle clave es que “comprobar + almacenar” debe ser seguro frente a concurrencia. En la práctica, almacenas el registro de idempotencia con una restricción única en (scope, key) y tratas los conflictos como una señal para reutilizar.
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
Ejemplo: un cliente pulsa “Crear factura”, la app envía la clave abc123 y el servidor crea la factura inv_1007. Si el teléfono pierde señal y reintenta, el servidor responde con la misma inv_1007, no con inv_1008.
Cuando pruebes, no te quedes en “doble clic”. Simula una petición que hace timeout en el cliente pero que se completa en el servidor, y luego reintenta con la misma clave.
Las defensas en el servidor importan, pero muchos duplicados empiezan con una persona repitiendo una acción normal. Una buena UI hace que el camino seguro sea obvio.
Desactiva el botón de envío tan pronto como el usuario envíe. Hazlo en el primer clic, no tras validar o después de que empiece la petición. Si el formulario puede enviarse desde varios controles (un botón y Enter), bloquea todo el estado del formulario, no solo un botón.
Muestra un estado de progreso claro que responda a una pregunta: ¿está funcionando? Una simple etiqueta “Guardando...” o un spinner basta. Mantén el diseño estable para que el botón no salte y provoque un segundo clic.
Un pequeño conjunto de reglas previene la mayoría de envíos dobles: establece un flag isSubmitting al inicio del handler de envío, ignora nuevos envíos mientras sea true (tanto para click como para Enter) y no lo vuelvas a poner en false hasta recibir una respuesta real.
Las respuestas lentas son donde muchas apps fallan. Si vuelves a habilitar el botón en un temporizador fijo (por ejemplo tras 2 segundos), los usuarios pueden enviar otra vez mientras la primera petición sigue en vuelo. Vuelve a habilitar solo cuando el intento termine.
Tras el éxito, haz que la resubmisión sea poco probable. Navega a otra pantalla (la página del registro nuevo o la lista) o muestra un estado claro de éxito con el registro creado visible. Evita dejar el mismo formulario rellenado en pantalla con el botón habilitado.
Los bugs de duplicados más persistentes vienen de comportamientos “raros pero comunes”: dos pestañas, un refresco o un teléfono que pierde señal.
Primero, define correctamente el ámbito de unicidad. “Único” raramente significa “único en toda la base de datos”. Puede significar uno por usuario, uno por workspace o uno por tenant. Si sincronizas con un sistema externo, puede que necesites unicidad por fuente externa más su ID externo. Un enfoque seguro es escribir la frase exacta que quieres (por ejemplo, “Un número de factura por tenant por año”) y hacerla cumplir.
El comportamiento multi-pestaña es una trampa clásica. Los estados de carga en la UI ayudan en una pestaña, pero no hacen nada entre pestañas. Ahí es donde las defensas del servidor deben sostenerse.
El botón Atrás y el refresco pueden disparar reenvíos accidentales. Tras una creación exitosa, los usuarios a menudo refrescan para “comprobar” o pulsan Atrás y vuelven a enviar un formulario que todavía parece editable. Prefiere una vista del registro creado en lugar del formulario original, y haz que el servidor maneje reproducciones seguras.
Móvil añade interrupciones: pasar la app a segundo plano, redes inestables y reintentos automáticos. Una petición puede tener éxito pero la app no recibir la respuesta, así que lo intenta de nuevo al reanudar.
El modo de fallo más común es tratar la UI como la única salvaguardia. Un botón desactivado y un spinner ayudan, pero no cubren refrescos, redes móviles inestables, usuarios con varias pestañas o bugs del cliente. El servidor y la base de datos todavía deben poder decir “esta creación ya ocurrió”.
Otra trampa es elegir el campo equivocado para la unicidad. Si pones una restricción única en algo que no es realmente único (un apellido, una marca de tiempo redondeada, un título libre), bloquearás registros válidos. En su lugar, usa un identificador real (como un ID de proveedor externo) o una regla con ámbito (único por usuario, por día o por registro padre).
Las claves de idempotencia también son fáciles de implementar mal. Si el cliente genera una clave nueva en cada reintento, obtendrás una creación nueva cada vez. Mantén la misma clave para toda la intención del usuario, desde el primer clic hasta cualquier reintento.
También vigila qué devuelves en reintentos. Si la primera petición creó el registro, un reintento debe devolver el mismo resultado (o al menos el mismo ID), no un error vago que haga al usuario volver a intentar.
Si una restricción única bloquea un duplicado, no lo escondas con “Algo salió mal”. Di lo que pasó en lenguaje claro: “Este número de factura ya existe. Conservamos el original y no creamos un segundo.”
Antes del lanzamiento, haz una pasada específica sobre rutas de creación. Los mejores resultados vienen de apilar defensas para que un clic perdido, un reintento o una red lenta no puedan crear dos filas.
Confirma tres cosas:
Una comprobación práctica: abre el formulario, haz clic dos veces rápidamente, luego refresca a mitad de envío y prueba otra vez. Si puedes crear dos registros, usuarios reales también lo harán.
Imagina una pequeña app de facturación. Un usuario rellena una factura nueva y pulsa Crear. La red está lenta, la pantalla no cambia de inmediato y pulsa Crear otra vez.
Solo con protección en la UI, puedes desactivar el botón y mostrar un spinner. Eso ayuda, pero no es suficiente. Un doble tap puede colarse en algunos dispositivos, puede haber un reintento tras un timeout, o el usuario puede enviar desde dos pestañas.
Solo con una restricción única en la base de datos puedes detener duplicados exactos, pero la experiencia puede ser brusca. La primera petición tiene éxito, la segunda choca con la restricción y el usuario ve un error aunque la factura se creó.
El resultado limpio es idempotencia más restricción única:
Un mensaje UI simple tras el segundo clic: “Factura creada: ignoramos el envío duplicado y mantuvimos tu primera petición.”
Una vez tengas la base, las siguientes mejoras van de visibilidad, limpieza y consistencia.
Añade logging ligero alrededor de las rutas de creación para distinguir entre una acción real de usuario y un reintento. Registra la clave de idempotencia, los campos únicos implicados y el resultado (creado vs devuelto existente vs rechazado). No necesitas herramientas pesadas para empezar.
Si ya existen duplicados, límpialos con una regla clara y un rastro de auditoría. Por ejemplo, conserva el registro más antiguo como “ganador”, vuelve a adjuntar filas relacionadas (pagos, líneas) y marca los otros como fusionados en lugar de borrarlos. Eso facilita soporte e informes.
Escribe tus reglas de unicidad e idempotencia en un solo lugar: qué es único y en qué ámbito, cuánto duran las claves de idempotencia, cómo son los errores y qué debe hacer la UI en reintentos. Esto evita que nuevos endpoints eludan las medidas de seguridad.
Si estás construyendo pantallas CRUD rápidamente en Koder.ai (koder.ai), vale la pena hacer que estos comportamientos sean parte de tu plantilla por defecto: restricciones únicas en el esquema, endpoints de creación idempotentes en la API y estados de carga claros en la UI. Así, la velocidad no sale a costa de datos desordenados.
Un registro duplicado es cuando la misma entidad del mundo real se guarda dos veces, por ejemplo dos pedidos por un mismo pago o dos incidencias con los mismos datos. Suele ocurrir porque la misma acción de “crear” se ejecuta más de una vez debido a clics dobles del usuario, reintentos o peticiones concurrentes.
Porque una segunda creación puede activarse sin que el usuario lo note: un doble tap en móvil, pulsar Enter y luego el botón, etc. Además, el cliente, la red o el servidor pueden reintentar la petición tras un timeout, y el servidor no puede asumir que “POST significa una vez”.
No de forma fiable. Desactivar el botón y mostrar “Guardando…” reduce envíos accidentales, pero no evita reintentos por redes inestables, refrescos de página, múltiples pestañas, workers en background o reenvíos de webhooks. También hacen falta defensas en servidor y base de datos.
La restricción única en la base de datos es la última línea de defensa que impide insertar dos filas aunque lleguen dos peticiones al mismo tiempo. Funciona mejor cuando defines una regla real de unicidad (a menudo con ámbito, por ejemplo por tenant o workspace) y la aplicas directamente en la base de datos.
Resuelven problemas distintos. Las restricciones únicas bloquean duplicados según una regla de campo (por ejemplo número de factura), mientras que las claves de idempotencia hacen que un intento concreto de creación sea seguro de repetir (la misma clave devuelve el mismo resultado). Usar ambos te da seguridad y una mejor experiencia al reintentar.
Genera una clave por la intención del usuario (un solo “Crear”), reutilízala para cualquier reintento de esa intención y envíala con la petición cada vez. La clave debe ser estable ante timeouts y reanudaciones de la app, pero no debe reutilizarse para otra creación distinta.
Almacena un registro de idempotencia con el ámbito claro (por ejemplo usuario o cuenta), el endpoint y la clave, y guarda la respuesta que devolviste en el primer intento exitoso. Si llega la misma clave otra vez, devuelve la respuesta guardada con el mismo ID de registro creado en vez de crear una nueva fila.
Usa un enfoque seguro frente a concurrencia “comprobar + almacenar”, normalmente imponiendo una restricción única en el propio registro de idempotencia (para ámbito + clave). Así, dos peticiones casi simultáneas no pueden ambas considerarse “la primera” y una tendrá que reutilizar el resultado guardado.
Guárdalas el tiempo suficiente para cubrir reintentos realistas; un valor habitual son 24 horas, y más para flujos como pagos donde los reintentos pueden ocurrir pasadas más horas. Añade un TTL para que el almacenamiento no crezca indefinidamente y ajusta el TTL a cuánto tiempo es razonable que el cliente vuelva a intentar.
Trata un reintento duplicado como un retry exitoso cuando claramente responde a la misma intención, y devuelve el registro original (mismo ID) en lugar de un error vago. Si la creación debe ser única (por ejemplo un email), muestra un mensaje de conflicto claro que explique qué existe y qué se hizo al respecto.