Post-mortems y lecciones reales

Retos Técnicos

Problemas reales que enfrenté en producción, cómo los diagnostiqué y qué lecciones me dejaron. Esto no es teoría, son batallas que peleé y de las que aprendí.

8

Post-mortems documentados

48h

Promedio de resolución

100%

Resueltos en producción

01Complejidad Alta
RendimientoJVMBackend
Marzo 202512 min

Optimizar verificación ArrayList.contains() con operaciones de bits

El problema

En StellarProtect (plugin Minecraft), el hot-path verificaba constantemente si un material estaba en una lista de materiales protegidos usando ArrayList.contains(). Con 200+ jugadores interactuando con miles de bloques por segundo, esta operación O(n) estaba causando stuttering en el hilo principal. El profiler mostró que el 12% del tiempo de tick se gastaba en estas verificaciones lineales.

Solución

  1. 1Convertí la lista de materiales a un BitSet donde cada bit representa si un material (por su ordinal) está presente.
  2. 2La verificación de pertenencia cambió de O(n) a O(1): set.get(material.ordinal()) es una simple operación AND bit a bit con máscara.
  3. 3Para hash maps con claves potencia de 2, reemplacé hash % capacity por hash & (capacity - 1), que es 10-20x más rápido según JMH.
  4. 4En generación de IDs compactos usé desplazamientos (value << 8) en lugar de multiplicación (value * 256), porque el compilador no siempre optimiza esto.
  5. 5Para comparaciones insensibles a mayúsculas, forcé minúsculas con c | 0x20 en lugar de Character.toLowerCase() para evitar llamadas de método.
  6. 6Máscaras & 0xFF para prevenir extensión de signo al convertir bytes a ints y evitar valores negativos inesperados.

Aprendizajes

  • BitSet es extremadamente eficiente para conjuntos de enteros densos: usa 1 bit por elemento vs 32+ bytes por entrada en HashSet.
  • Las operaciones bit a bit son primitivas de CPU: AND/OR/XOR son una sola instrucción vs llamadas de método con overhead.
  • El módulo con potencias de 2 SIEMPRE debe usar & (n-1) en hot-paths porque es una optimización que el JIT no garantiza.
  • Los desplazamientos de bits (<<, >>) son útiles más allá de multiplicar/dividir por 2, también para empaquetar datos en enteros.
  • Las máscaras bit a bit eliminan ramas: if (c >= A && c <= Z) se convierte en una operación sin salto condicional.
Stack —JavaBitSetJMHBitwiseBukkitProfiling
02Complejidad Crítica
SeguridadAndroidBackendE2EE
Diciembre 202514 min

Implementar E2EE zero-knowledge en Android + Node.js

El problema

En Suba necesitaba que los datos de clientes (nombre, teléfono, DNI, deudas) fueran completamente privados, incluso para mí como proveedor del servicio. El servidor no podía conocer los datos en texto plano. El reto fue implementar un sistema donde la clave de cifrado se derive de la contraseña del usuario sin exponer esa contraseña, y que funcione tanto en Android como en la web, con recuperación de emergencia.

Solución

  1. 1Usé Argon2id para derivación de clave: resistente a ataques GPU/ASIC, configurable por tiempo/memoria.
  2. 2Generé un par RSA-4096 por usuario. La clave privada se cifra con AES-256-GCM usando la clave derivada de Argon2.
  3. 3Implementé "Digital Envelopes": la clave AES del negocio se cifra con la clave pública de cada usuario autorizado.
  4. 4Para múltiples empleados: cada invitación genera un sobre nuevo cifrado con la clave pública del empleado.
  5. 5Sistema de recuperación de emergencia con frase semilla BIP39 de 12 palabras como último recurso offline.
  6. 6El servidor almacena únicamente datos cifrados sin capacidad de descifrarlos.

Aprendizajes

  • Argon2id > bcrypt para derivación de clave: el parámetro de memoria lo hace inapropiado para GPUs.
  • Los "Digital Envelopes" resuelven elegantemente el problema de múltiples usuarios con acceso a los mismos datos.
  • Zero-knowledge real es difícil de implementar con búsquedas. Lo resolví con HMAC-SHA256 de prefijos para búsqueda cifrada.
  • La recuperación de emergencia debe diseñarse desde el inicio, no añadirse después.
Stack —KotlinArgon2idRSA-4096AES-256-GCMNode.jsBIP39
03Complejidad Alta
AndroidBackendConcurrencia
Enero 202611 min

Sincronización offline con conflictos de datos entre dispositivos

El problema

En Suba el modo offline era fundamental: un vendedor sin internet no puede dejar de vender. El reto fue diseñar un sistema que permita crear ventas, actualizar stock y registrar gastos sin conexión, y luego sincronizar todo cuando vuelve la red sin corromper datos si otro dispositivo modificó el mismo producto mientras tanto.

Solución

  1. 1Usé Room SQLite para cache local con cola de operaciones pendientes: CREATE_SALE, UPDATE_PRODUCT, UPDATE_STOCK.
  2. 2Cada operación encolada guarda performedAt (epoch ms) para comparar con el servidor.
  3. 3WorkManager con política RETRY y hasta 5 reintentos procesa la cola FIFO al recuperar conexión.
  4. 4En el backend: PUT /api/v2/products/:id acepta performedAt. Si product.updatedAt > performedAt, ignora el cambio y retorna { ignored: true, currentData }.
  5. 5El cliente al recibir { ignored: true } descarta su cambio y recarga el estado del servidor.
  6. 6Banner naranja visible en la UI cuando no hay conexión, mostrando operaciones pendientes y antigüedad del cache.

Aprendizajes

  • Last-Write-Wins sin timestamps produce pérdida de datos silenciosa. Siempre graba cuándo ocurrió la operación.
  • El servidor es el árbitro final en conflictos. El cliente debe aceptar y mostrar el estado real.
  • WorkManager es la opción correcta en Android para tareas de background persistentes (sobrevive a reinicios).
  • Room + Flow permite que la UI reaccione automáticamente a cambios de sincronización sin polling.
Stack —KotlinRoomWorkManagerNode.jsMongoDB
04Complejidad Media
Base de datosBackend
Noviembre 20257 min

Soft delete con retención por plan en un SaaS multi-tier

El problema

Suba tiene 4 planes con tiempos de retención distintos (7/30/60/90 días). Cuando un usuario "elimina" un producto, la expectativa de negocio es que pueda restaurarlo dentro del período de su plan. El reto fue diseñar un sistema que archive sin eliminar, limpie automáticamente al expirar, y que los elementos archivados nunca aparezcan en ningún listado sin añadir complejidad a cada query de la app.

Solución

  1. 1Cada documento tiene isArchived: boolean, archivedAt: Date y autoDeleteAt: Date.
  2. 2El DELETE en la API establece isArchived=true, archivedAt=now, autoDeleteAt=now+retentionDays según el plan del propietario.
  3. 3MongoDB TTL index: { autoDeleteAt: 1, expireAfterSeconds: 0, sparse: true } elimina físicamente el documento al llegar la fecha.
  4. 4Todos los GET de listado usan { isArchived: { $ne: true } } o { archivedAt: null } como filtro base.
  5. 5Archivado de producto con stock negativo: updateOne con runValidators: false para evitar errores de validación durante el archive.
  6. 6Job nocturno (1 AM) borra imágenes del disco cuando autoDeleteAt <= now + 24h, no al archivar, respetando el período de restauración.

Aprendizajes

  • El TTL de MongoDB es la forma más elegante de gestionar expiración automática sin cron jobs manuales.
  • sparse: true en el índice TTL es esencial para que documentos sin autoDeleteAt no sean afectados.
  • Separar la limpieza de imágenes del archivado fue clave: borrar la imagen al archivar rompía la restauración.
  • runValidators: false es una trampa necesaria cuando tienes datos heredados que violan constraints actuales.
Stack —Node.jsMongoDBTypeScriptTTL Index
05Complejidad Media
SeguridadAndroidBackend
Octubre 20258 min

Imágenes privadas de negocio accesibles sin exponer el storage

El problema

Suba permite subir imágenes de productos, comprobantes de pago (Yape/Plin) y fotos de perfil. El problema: las imágenes no podían ser públicas porque un empleado de la bodega A no debe poder ver las imágenes de la bodega B simplemente adivinando la URL. Pero tampoco podía usar un CDN de terceros por costos y privacidad de datos sensibles (comprobantes con montos reales).

Solución

  1. 1Implementé un sistema de tokens firmados en el backend: cada request de imagen genera un token JWT corto (5 min de expiración) con businessId + userId + imageId.
  2. 2El endpoint de imagen verifica el token antes de servir el archivo, rechazando cualquier acceso sin token válido.
  3. 3En Android, el ImageLoader (Coil) inyecta el token en el header de cada petición de imagen vía OkHttp Interceptor.
  4. 4Los tokens se emiten en el mismo response que la entidad (producto, venta), no en una llamada separada.
  5. 5Rotación automática: si el token expira mientras la imagen está en pantalla, el interceptor solicita uno nuevo y reintenta.

Aprendizajes

  • Las presigned URLs de S3 son el patrón estándar para esto. Mi implementación es esencialmente lo mismo, pero propia.
  • El JWT de corta duración para recursos estáticos es mucho más seguro que URLs firmadas con HMAC sin expiración.
  • Coil + OkHttp Interceptor es la combinación correcta para autenticar imágenes en Android sin modificar cada Image().
  • No mezcles autenticación de la API y autenticación de assets — tienen ciclos de vida distintos.
Stack —Node.jsJWTKotlinCoilOkHttp
06Complejidad Alta
ConcurrenciaBase de datosBackend
Febrero 20269 min

Race condition en la deuda del cliente con ventas simultáneas

El problema

En Suba las ventas a crédito actualizan el campo currentDebt del cliente. En negocios con varios empleados usando la app simultáneamente, dos ventas al mismo cliente podían ejecutarse en paralelo. El patrón era: leer deuda actual (500), calcular nueva (500+200=700), guardar 700. Pero si otra operación hizo lo mismo en paralelo, ambas leían 500 y la segunda escritura sobreescribía a la primera.

Solución

  1. 1Identifiqué la race condition con logs: dos ventas al mismo cliente en el mismo segundo producían una deuda incorrecta.
  2. 2Reemplacé el patrón read-calculate-write con una operación atómica en MongoDB: findOneAndUpdate con $inc.
  3. 3customer.currentDebt: { $inc: amount } es atómica a nivel de documento — MongoDB garantiza que no hay interleaving.
  4. 4Añadí un Optimistic Lock con el campo version: cada operación de crédito lee la versión actual e incluye { version } en el filtro del update. Si la versión ya cambió, reintenta.
  5. 5Para el caso extremo de ventas con múltiples métodos de pago, envolví las operaciones de crédito en un bloque serializado con un distributed lock Redis.

Aprendizajes

  • $inc en MongoDB es atómico a nivel de documento. Es la forma correcta de actualizar contadores concurrentes.
  • Optimistic locking con campo version es mejor que pessimistic locking para la mayoría de casos de escritura.
  • Los distributed locks de Redis son costosos. Úsalos solo cuando la atomicidad de MongoDB no sea suficiente.
  • Los tests de concurrencia son difíciles pero esenciales: simula 10 requests simultáneos al mismo recurso en cada test.
Stack —Node.jsMongoDBRedisTypeScript
07Complejidad Media
BackendArquitectura
Septiembre 20258 min

Aplicar límites de plan SaaS sin ensuciar toda la lógica de negocio

El problema

Suba tiene 4 planes con features distintas: el plan Free no puede tener combos, el Básico no puede acceder a lotes, el Profesional no puede usar mesas. Inicialmente empecé a añadir verificaciones de plan dentro de cada controller y el resultado fue código acoplado, difícil de probar y lleno de duplicación. Cambiar un límite de plan requería buscar en 20 archivos.

Solución

  1. 1Extraje toda la lógica de planes a un middleware: subscriptionGuard + requireFeature(featureName).
  2. 2requireFeature toma un string como "combosEnabled" y verifica el plan antes de que el request llegue al controller.
  3. 3Los feature flags viven en un único objeto de configuración por plan. Cambiar un límite es editar un solo lugar.
  4. 4Los controllers no saben nada de planes: si el request llega al controller, el usuario tiene acceso.
  5. 5Respuestas estructuradas: { code: "SUBSCRIPTION_REQUIRED", requiredPlan, currentPlan, feature } para que el cliente Android muestre el CTA correcto.
  6. 6Endpoint /api/feature-flags retorna todas las features activas del plan actual — la app Android lo consulta al login para cachear los permisos localmente.

Aprendizajes

  • Los middlewares son el lugar correcto para cross-cutting concerns como autorización y feature flags.
  • Un único source of truth para configuración de planes elimina bugs de inconsistencia entre endpoints.
  • Diseñar las respuestas de error para el cliente es tan importante como la lógica del servidor. El CTA al upgrade debe tener contexto.
  • Cachear los feature flags en el cliente evita una petición de red extra en cada pantalla.
Stack —Node.jsTypeScriptExpressKotlin
08Complejidad Media
AndroidRendimiento
Agosto 20257 min

Detectar pagos Yape/Plin automáticamente desde capturas de pantalla

El problema

En Perú, muchos clientes pagan con Yape o Plin y muestran su pantalla al vendedor. El flujo manual era: el vendedor toma captura, adjunta el comprobante, lo llena manualmente. Quería que la app detectara el monto y los datos del pago directamente desde la captura de pantalla del cliente.

Solución

  1. 1Usé la API de Google ML Kit Text Recognition (on-device, sin internet) para extraer texto de la captura.
  2. 2Implementé patterns de regex para el formato típico de Yape: "S/ X.XX", nombre del receptor, fecha y hora.
  3. 3Se presenta el resultado como sugerencia, no como dato definitivo. El cajero confirma antes de registrar.
  4. 4Para Plin: el formato de comprobante es diferente, tuve que entrenar los patterns por separado con casos reales de prueba.
  5. 5Si el OCR no detecta datos con suficiente confianza (< 0.85), el sistema cae al modo manual sin interrumpir el flujo.

Aprendizajes

  • ML Kit Text Recognition on-device es sorprendentemente preciso para comprobantes con fuentes estándar.
  • Nunca confíes 100% en OCR para datos financieros. La confirmación humana es una capa de seguridad, no burocracia.
  • Los regex para formatos de pago peruanos requieren muchos casos de prueba reales: los montos pueden tener punto o coma, los nombres tienen acentos.
  • Fail gracefully: si el OCR falla, el flujo manual debe ser igual de accesible. Nunca bloquees el flujo principal.
Stack —KotlinJetpack ComposeML KitAndroid

¿Tienes un problema similar?

Cuéntame tu caso

Si enfrentas un reto técnico parecido a los de esta lista, puedo ayudarte a diagnosticar y resolver el problema.

Escribirme →