Portafolio · Parte de nutri.

Producto personal · nutri.

Registrar la misma comida tres veces — escrituras sin conexión y una cola idempotente

Con una conexión débil, nutri. llegaba a guardar dos o tres veces una misma entrada. La solución fue mover la escritura a una cola local y hacer que repetirla no produjera duplicados.

Sin conexión · Idempotencia · PWA · Sincronización local

El reporte de los dos únicos usuarios de la aplicación fue concreto: con mala conexión la interfaz no responde; registro mi comida tres veces y, segundos después, aparecen las tres. Era un problema de integridad de datos con apariencia de problema de UX. La solución obvia —un indicador de carga o desactivar el botón— solo atendía la apariencia.

El duplicado venía de la espera

El insert esperaba la respuesta de la red antes de mover nada en pantalla. Con buena conexión pasaba inadvertido; en una estación del metro podía significar veinte segundos con un formulario abierto y un botón que parecía intacto. El usuario tocaba de nuevo. Al final, el servidor recibía tres inserts válidos.

Bloquear el botón no corrige esa clase de problema: la interfaz estaba informando el estado de la red en lugar de registrar la intención del usuario. La intención quedó expresada en el primer toque.

La idempotencia es una decisión de esquema, no una política de reintentos

Ahora las escrituras entran en una cola de localStorage y la fila aparece en el mismo toque. La red trabaja detrás; la cola se procesa al recuperar conexión, al volver la app a primer plano y al montarse.

El mecanismo que elimina el duplicado es más pequeño que la cola: el UUID de la fila se genera en el cliente y viaja dentro del insert. Antes Postgres lo generaba en el servidor. Al decidirlo en el cliente, todos los reintentos representan la misma escritura. Repetir un insert que ya llegó choca con la clave primaria —23505— y la cola lo trata como terminado. La garantía contra duplicados reside en una restricción que la base de datos ya aplicaba, no en una bandera que alguien deba recordar consultar.

Otra regla hace irrelevante el orden del replay: como máximo una operación pendiente por identificador. Borrar un insert aún pendiente lo elimina de la cola; el servidor nunca recibe una fila creada y borrada sin conexión. Una edición se incorpora al insert pendiente, de modo que la entrada llega al servidor una sola vez, ya corregida.

Qué deja fuera la cola deliberadamente

Copiar un día, usar plantillas de comidas, importar en lote, reordenar arrastrando y «borrar día» siguen requiriendo conexión y fallan de forma visible, como antes. Son operaciones sobre varias filas con reglas de orden; ponerlas en cola no aportaría lo suficiente para justificar un modelo de resolución de conflictos. Las filas pendientes tampoco pueden arrastrarse: sort_order se escribe por identificador, algo que no tiene sentido antes de que exista la fila. Mantener el alcance acotado permitió construir una cola en lugar de un motor de sincronización.

Un inicio sin conexión necesita catálogo, no solo cola

Una cola solo ayuda dentro de una sesión que ya cargó. Abrir la PWA instalada sin conexión seguía mostrando una app vacía: sin alimentos para buscar, no se podía registrar nada. La caché de sesión pasó a persistirse con una lista explícita: catálogo de alimentos y recetas, etiquetas, objetivos, entradas de hoy y valores por 100 g de los alimentos ya usados. La lista es explícita porque una caché abierta crecería con cada día y rango del panel hasta agotar la cuota; si de todos modos falla la escritura, se desactiva la persistencia y todo sigue funcionando en memoria.

La última pieza importa: sin valores por 100 g en caché, un alimento elegido sin conexión se registraría con nutrientes nulos. Una función del cliente calcula provisionalmente los números visibles de la entrada en cola para reflejar la vista SQL; esa vista los sustituye cuando se sincroniza. Son valores provisionales derivados de datos conocidos, no cifras inventadas.

El detalle que justificó documentarlo

Sin conexión, el cliente de Supabase rechaza la promesa; no devuelve { error }. Todas las rutas de fallo que solo revisaban error quedaban inactivas. La búsqueda sin conexión terminaba en una lista vacía que parecía, de forma convincente, un catálogo sin coincidencias. Un try corrigió esa ruta; la lección es que el contrato de fallo de una biblioteca cambia precisamente en la condición que rara vez se prueba.

Verificado al interrumpir la red a propósito

Con window.fetch configurado para rechazar toda solicitud a Supabase, a 375 px y contra la base de producción con una cuenta de prueba: la búsqueda devolvió alimentos del catálogo persistido; registrar 150 g de huevo entero mostró 232.5 kcal, 19.5 g de proteína y 186 mg de sodio; editar se incorporó al insert pendiente; borrar y deshacer funcionaron; la cola sobrevivió a una recarga completa y se procesó al reconectar. Al final quedó exactamente una fila en el servidor y los valores de la vista coincidían con los calculados localmente.


La lección de producto: «la app se siente lenta con mal wifi» y «la app corrompe mis datos» eran el mismo incidente, pero solo el segundo encuadre lleva a priorizarlo bien. La interfaz optimista es la mitad visible; la mitad que la hace segura consiste en decidir quién genera el identificador y dejar que una restricción de base de datos —no una política de reintentos— garantice una sola escritura.

BR / CONTACTO

Hablemos

Para oportunidades profesionales y conversaciones sobre producto, pagos e IA aplicada.