Evitar registros duplicados en aplicaciones CRUD: defensas prácticas
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.

Qué son los registros duplicados y por qué ocurren
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.
Dónde se cuelan los duplicados (cliente, red, servidor)
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.
Lado cliente: el usuario envía dos veces
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.
Red y transporte: la petición se reenvía
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.
Lado servidor: reintentos, webhooks y workers en background
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.
Concurrencia: dos peticiones en carrera
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.
Empieza por restricciones únicas en la base de datos (la última línea de defensa)
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:
- Dirección de email para un usuario
- Número de pedido para una compra
- Un external_id de un proveedor de pagos
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ó").
Usa patrones de escritura seguros cuando encajen con tus reglas
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.
Claves de idempotencia: haz seguras las creaciones repetidas
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.
Paso a paso: añade idempotencia a un endpoint de creación
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.
Una receta práctica en 5 pasos
- Crea una clave de idempotencia por cada intención del usuario (por ejemplo, cuando pulsa “Crear factura”). Générala en el cliente y mantenla estable para esa acción única.
- Envía la clave con la petición cada vez que intentes esa creación. Un header funciona bien (como
Idempotency-Key), pero enviarla en el cuerpo JSON también sirve. - En el servidor, guarda la clave con un ámbito claro: quién llama (usuario o cuenta), a qué endpoint aplica y qué creación representa. Guarda también la carga de la respuesta final.
- Si llega la misma clave otra vez para el mismo ámbito, no crees nada. Devuelve la respuesta almacenada (incluyendo el ID del registro original).
- Pruébalo bajo estrés: clics rápidos, reintentos cliente y timeouts de red simulados. Confirma que obtienes exactamente un registro nuevo.
Cómo luce la lógica en el servidor
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.
Medidas en la UI que evitan envíos dobles accidentales
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.
Casos límite para planear (pestañas, refresco, móvil)
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.
Errores comunes y trampas
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.”
Lista de comprobación rápida antes de publicar
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:
- Las reglas en la base de datos coinciden con la realidad (las restricciones únicas existen en producción y están bien acotadas).
- Los endpoints importantes de creación son seguros de repetir (las claves de idempotencia devuelven el mismo resultado).
- La UI deja el estado claro (submit desactivado, progreso visible y una vista clara tras el éxito).
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.
Ejemplo: crear una factura sin duplicados
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:
- El cliente genera una clave de idempotencia para la acción de crear y la reutiliza en reintentos.
- El servidor guarda la clave con el resultado de la creación y devuelve el mismo ID de factura en repeticiones.
- La restricción única te protege si dos claves diferentes intentan crear el mismo número de factura.
Un mensaje UI simple tras el segundo clic: “Factura creada: ignoramos el envío duplicado y mantuvimos tu primera petición.”
Siguientes pasos: mantenerlo fiable a medida que la app crece
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.
Preguntas frecuentes
¿Qué cuenta exactamente como un registro duplicado en una app CRUD?
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.
¿Por qué ocurren duplicados aunque los usuarios solo hagan clic una vez?
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”.
¿Desactivar el botón de envío es suficiente para evitar duplicados?
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.
¿En qué debería poner una restricción única en la 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.
¿Necesito tanto restricciones únicas como claves de idempotencia?
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.
¿Cómo debe el cliente generar y reutilizar una clave de idempotencia?
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.
¿Qué debe hacer el servidor cuando recibe de nuevo la misma clave de idempotencia?
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.
¿Cómo hago que la idempotencia sea segura frente a condiciones de carrera?
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.
¿Cuánto tiempo debo conservar los registros de idempotencia?
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.
¿Cuál es el mejor comportamiento visible para el usuario cuando se detecta una creación duplicada?
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.