Kotlin para redes lentas: timeouts y reintentos seguros
Kotlin práctico para redes lentas: configura timeouts, cachea con seguridad, reintenta sin duplicados y protege acciones críticas en redes móviles inestables.

Qué falla en conexiones lentas o inestables
En móvil, “lento” no suele significar “sin internet”. A menudo es una conexión que funciona solo a ráfagas. Una petición puede tardar 8 a 20 segundos, estancarse a medias y luego terminar. O puede funcionar un momento y fallar al siguiente porque el teléfono pasó de Wi‑Fi a LTE, entró en una zona de baja señal o el SO puso la app en segundo plano.
“Inestable” es peor. Paquetes se pierden, búsquedas DNS agotan el tiempo, los handshakes TLS fallan y las conexiones se reinician aleatoriamente. Puedes hacer todo “bien” en código y aun así ver fallos en producción porque la red cambia bajo tus pies.
Aquí es donde las configuraciones por defecto suelen fallar. Muchas apps confían en los valores por defecto de librerías para timeouts, reintentos y caché sin decidir qué significa “suficiente” para la gente real. Esos valores suelen estar afinados para Wi‑Fi estable y APIs rápidas, no para un tren de cercanías, un ascensor o una cafetería concurrida.
Los usuarios no describen “socket timeouts” o “HTTP 503”. Notan síntomas: spinners que no terminan, errores súbitos tras una larga espera (luego funciona en el siguiente intento), acciones duplicadas (dos reservas, dos pedidos, cargos dobles), actualizaciones perdidas y estados mixtos donde la UI dice “falló” pero el servidor realmente procesó la petición.
Las redes lentas convierten pequeñas lagunas de diseño en problemas de dinero y confianza. Si la app no separa claramente “enviando” de “falló” y de “hecho”, los usuarios vuelven a tocar. Si el cliente reintenta a ciegas, puede crear duplicados. Si el servidor no soporta idempotencia, una conexión inestable puede producir múltiples escrituras “exitosas”.
"Acciones críticas" son todo lo que debe pasar como máximo una vez y debe ser correcto: pagos, envíos de checkout, reservar un hueco, transferir puntos, cambiar contraseña, guardar dirección de envío, enviar una reclamación o aprobar algo.
Ejemplo realista: alguien envía el checkout con LTE débil. La app manda la petición y la conexión cae antes de recibir respuesta. El usuario ve un error, toca "Pagar" otra vez y llegan dos peticiones al servidor. Sin reglas claras, la app no sabe si debe reintentar, esperar o parar. El usuario no sabe si debe intentar de nuevo.
Decide tus reglas antes de tocar el código
Cuando las conexiones son lentas o inestables, la mayoría de los bugs vienen de reglas poco claras, no del cliente HTTP. Antes de tocar timeouts, caché o reintentos, escribe qué significa “correcto” para tu app.
Empieza por las acciones que nunca deben ejecutarse dos veces. Suelen ser acciones de dinero y cuenta: hacer pedido, cobrar tarjeta, enviar un pago, cambiar contraseña, borrar cuenta. Si un usuario toca dos veces o la app reintenta, el servidor debería tratarlo como una sola solicitud. Si aún no puedes garantizar eso, marca esos endpoints como “sin reintentos automáticos” hasta que sí puedas.
Después, decide qué puede hacer cada pantalla cuando la red va mal. Algunas pantallas pueden ser útiles sin conexión (perfil conocido, pedidos previos). Otras deberían pasar a sólo lectura o mostrar un estado claro de “inténtalo de nuevo” (stock, precios en vivo). Mezclar expectativas causa UI confusa y caché arriesgado.
Fija tiempo de espera aceptable por acción según cómo piensa el usuario, no según lo “bonito” que quede en código. El login tolera una espera corta. Una subida de archivos necesita más tiempo. El checkout debe sentirse rápido pero también seguro. Un timeout de 30 segundos puede ser “fiable” en papel y aun así sentirse roto.
Por último, decide qué guardar en el dispositivo y durante cuánto tiempo. La caché ayuda, pero datos obsoletos pueden llevar a decisiones erróneas (precios antiguos, elegibilidad caducada).
Escribe las reglas en un lugar accesible (un README sirve). Manténlo simple:
- ¿Qué endpoints son “no duplicables” y requieren manejo de idempotencia?
- ¿Qué pantallas deben funcionar offline y cuáles son sólo lectura cuando no hay red?
- ¿Cuál es el tiempo máximo de espera por acción (login, refresco de feed, subida, checkout)?
- ¿Qué se puede cachear en el dispositivo y cuál es su tiempo de expiración?
- Tras un fallo, ¿muestras error, encolas para más tarde o pides reintento manual?
Con las reglas claras, los valores de timeout, cabeceras de caché, política de reintentos y estados de UI son mucho más fáciles de implementar y probar.
Timeouts que coinciden con las expectativas reales del usuario
Las redes lentas fallan de distintas maneras. Una buena configuración de timeouts no solo “elige un número”; se corresponde con lo que el usuario intenta hacer y falla lo bastante rápido como para que la app pueda recuperarse.
Los tres timeouts, en términos sencillos:
- Connect timeout: cuánto esperas para establecer la conexión con el servidor (lookup DNS, TCP, TLS). Si esto falla, la petición realmente no empezó.
- Write timeout: cuánto esperas mientras envías el cuerpo de la petición (subidas, JSON grande, uplink lento).
- Read timeout: cuánto esperas a que el servidor envíe datos de vuelta tras enviar la petición. Esto aparece mucho en redes móviles inestables.
Los timeouts deben reflejar la pantalla y lo que está en juego. Un feed puede ser más lento sin daño real. Una acción crítica debe completarse o fallar de forma clara para que el usuario decida qué hacer.
Punto de partida práctico (ajusta tras medir):
- Carga de listados (bajo riesgo): connect 5–10s, read 20–30s, write 10–15s.
- Búsqueda al escribir: connect 3–5s, read 5–10s, write 5–10s.
- Acciones críticas (alto riesgo, como “Pagar” o “Enviar pedido”): connect 5–10s, read 30–60s, write 15–30s.
La consistencia importa más que la perfección. Si el usuario toca “Enviar” y ve un spinner durante dos minutos, volverá a tocar.
Evita “cargas infinitas” añadiendo un límite superior en la UI. Muestra progreso de inmediato, permite cancelar y, después de (por ejemplo) 20–30 segundos, muestra “Seguimos intentando…” con opciones para reintentar o comprobar la conexión. Eso mantiene la experiencia honesta aunque la librería de red todavía espere.
Cuando ocurre un timeout, registra suficiente información para depurar patrones más tarde, sin almacenar secretos. Campos útiles: path de la URL (no la query completa), método HTTP, estado (si lo hay), desglose de tiempos (connect vs write vs read si está disponible), tipo de red (Wi‑Fi, celular, modo avión), tamaño aproximado de petición/respuesta y un request ID para emparejar logs cliente/servidor.
Una configuración Kotlin simple y consistente
Cuando las conexiones son lentas, pequeñas inconsistencias en la configuración del cliente se vuelven grandes problemas. Una base limpia ayuda a depurar y da a cada petición las mismas reglas.
Un cliente, una política
Empieza por un único lugar donde construyas tu cliente HTTP (habitualmente un OkHttpClient usado por Retrofit). Pon lo básico ahí para que todas las peticiones se comporten igual: cabeceras por defecto (versión de app, locale, token de auth) y un User‑Agent claro, timeouts definidos una sola vez (no repartidos por llamadas), logging activable para debug y una decisión de reintentos central (aunque sea “sin reintentos automáticos”).
Aquí tienes un ejemplo pequeño que mantiene la configuración en un solo archivo:
val okHttp = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.writeTimeout(20, TimeUnit.SECONDS)
.callTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("User-Agent", "MyApp/${BuildConfig.VERSION_NAME}")
.header("Accept", "application/json")
.build()
chain.proceed(request)
}
.build()
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttp)
.addConverterFactory(MoshiConverterFactory.create())
.build()
Manejo de errores central que mapea a mensajes de usuario
Los errores de red no son solo “una excepción”. Si cada pantalla los maneja de forma distinta, los usuarios ven mensajes aleatorios.
Crea un mapeador que convierta fallos en un pequeño conjunto de resultados comprensibles por el usuario: sin conexión/modo avión, timeout, error de servidor (5xx), error de validación o auth (4xx) y un fallback desconocido.
Esto mantiene el texto de UI consistente (“Sin conexión” vs “Intentar de nuevo”) sin mostrar detalles técnicos.
Etiquetar y cancelar peticiones cuando las pantallas se cierran
En redes inestables, las llamadas pueden terminar tarde y actualizar una pantalla que ya no existe. Haz de la cancelación una regla estándar: cuando una pantalla se cierra, su trabajo se detiene.
Con Retrofit y Kotlin coroutines, cancelar el coroutine scope (por ejemplo en un ViewModel) cancela la llamada HTTP subyacente. Para llamadas sin coroutines, guarda la referencia al Call y llama a cancel(). También puedes etiquetar peticiones y cancelar grupos de llamadas al salir de una funcionalidad.
El trabajo en background no debe depender de la UI
Cualquier tarea importante que deba completarse (enviar un informe, sincronizar una cola, finalizar un envío) debe ejecutarse en un scheduler diseñado para ello. En Android, WorkManager es la elección habitual porque puede reintentar más tarde y sobrevivir a reinicios. Mantén las acciones de UI ligeras y deriva trabajo más largo a jobs en background cuando tenga sentido.
Reglas de caché seguras en móvil
El caché puede ser una gran ventaja en conexiones lentas porque reduce descargas repetidas y hace que las pantallas parezcan instantáneas. También puede ser un problema si muestra datos obsoletos en el momento equivocado, como un saldo antiguo o una dirección de envío desactualizada.
Un enfoque seguro es cachear solo lo que el usuario tolera que esté un poco viejo y forzar comprobaciones frescas para todo lo que afecte a dinero, seguridad o una decisión final.
Conceptos básicos de Cache‑Control en los que confiar
La mayoría de las reglas se reducen a unas pocas cabeceras:
max-age=60: puedes reutilizar la respuesta en caché durante 60 segundos sin preguntar al servidor.no-store: no guardes esta respuesta en absoluto (ideal para tokens y pantallas sensibles).must-revalidate: si expiró, debes comprobar con el servidor antes de usarla de nuevo.
En móvil, must-revalidate evita datos “silenciosamente incorrectos” tras un periodo offline. Si el usuario abre la app después de un viaje en metro, quieres una pantalla rápida, pero también quieres que la app confirme lo que sigue siendo cierto.
Revalidación con ETag: rápida, barata y fiable
Para endpoints de lectura, la validación con ETag suele ser mejor que valores largos de max-age. El servidor envía un ETag con la respuesta. La próxima vez, la app envía If-None-Match con ese valor. Si no cambió, el servidor responde 304 Not Modified, que es pequeño y rápido en redes débiles.
Esto funciona bien para listas de productos, detalles de perfil y pantallas de ajustes.
Regla simple:
- Cachea endpoints de “lectura” con
max-agecorto másmust-revalidate, y soportaETagcuando puedas. - No cachees endpoints de “escritura” (POST/PUT/PATCH/DELETE). Trátalos siempre como dependientes de la red.
- Usa
no-storepara todo lo sensible (respuestas de auth, pasos de pago, mensajes privados). - Cachea activos estáticos (iconos, config pública) por más tiempo, ya que el riesgo de obsolescencia es bajo.
Mantén decisiones de caché coherentes en toda la app. Los usuarios notan las incoherencias más que los pequeños retrasos.
Reintentos seguros sin empeorar la situación
Los reintentos parecen una solución fácil, pero pueden salir mal. Reintenta las peticiones equivocadas y creas carga extra, gastas batería y la app parece bloqueada. Reintenta las fallas que son probablemente temporales: una conexión caída, un timeout de lectura o una caída breve del servidor pueden funcionar en el siguiente intento. Un password incorrecto, un campo faltante o un 404 no lo harán.
Reglas prácticas:
- Reintenta timeouts y fallos de conexión.
- Reintenta 502, 503 y a veces 504.
- No reintentes 4xx (salvo 408 o 429 si tienes una regla de espera clara).
- No reintentes peticiones que ya llegaron al servidor y puedan estar en proceso.
- Mantén los reintentos bajos (a menudo 1 a 3 intentos).
Backoff + jitter: menos tormentas de reintentos
Si muchos usuarios golpean la misma caída, reintentos instantáneos pueden generar una ola de tráfico que ralentice la recuperación. Usa backoff exponencial (espera más cada vez) y añade jitter (un pequeño retraso aleatorio) para que los dispositivos no reintenten sincronizados.
Por ejemplo: espera ~0.5s, luego 1s, luego 2s, con un +/- 20% aleatorio cada vez.
Pon un tope al tiempo total de reintentos
Sin límites, los reintentos pueden atrapar a los usuarios en un spinner durante minutos. Elige un tiempo total máximo para toda la operación, incluyendo todas las esperas. Muchas apps apuntan a 10–20 segundos antes de parar y mostrar una opción clara de reintentar.
También adapta al contexto. Si alguien envía un formulario, quiere una respuesta pronto. Si un sync en background falla, puedes reintentar más tarde.
Nunca reintentes automáticamente acciones no idempotentes (como hacer un pedido o enviar un pago) a menos que tengas protección como una clave de idempotencia o comprobación de duplicados server‑side. Si no puedes garantizar seguridad, falla de forma clara y deja que el usuario decida.
Prevención de duplicados para acciones críticas
En conexiones lentas o inestables, los usuarios tocan dos veces. El SO puede reintentar en background. Tu app puede reenviar tras un timeout. Si la acción es “crear algo” (pedido, transferencia, cambiar contraseña), los duplicados dañan.
Idempotencia significa que la misma petición debe producir el mismo resultado. Si la petición se repite, el servidor no debe crear un segundo pedido; debe devolver el primer resultado o indicar “ya hecho”.
Usa una clave de idempotencia por cada intento crítico
Para acciones críticas, genera una clave de idempotencia única cuando el usuario inicia el intento y envíala con la petición (a menudo en una cabecera Idempotency-Key, o en un campo del body).
Flujo práctico:
- Crea un UUID de idempotencia cuando el usuario toca “Pagar”.
- Guárdalo localmente con un pequeño registro: status = pending, createdAt, hash del payload.
- Envía la petición con la clave.
- Al recibir éxito, marca status = done y guarda el ID de resultado del servidor.
- Si necesitas reintentar, reutiliza la misma clave, no una nueva.
Reusar la misma clave es lo que evita cargos dobles accidentales.
Maneja reinicios de app y huecos offline
Si la app se mata a mitad de petición, el siguiente inicio debe ser seguro. Almacena la clave de idempotencia y el estado de la petición en almacenamiento local (por ejemplo, una fila de DB pequeña). Al reiniciar, o bien reintentas con la misma clave o llamas a un endpoint “check status” usando la clave guardada o el ID del servidor.
En el servidor, el contrato debe ser claro: al recibir una clave duplicada, rechazar el segundo intento o devolver la respuesta original (mismo ID de pedido, mismo recibo). Si el servidor no puede hacerlo aún, la prevención de duplicados en el cliente nunca será totalmente fiable, porque la app no puede ver qué pasó tras enviar la petición.
Detalle de UX: si un intento está pendiente, muestra “Pago en curso” y desactiva el botón hasta obtener un resultado final.
Patrones de UI que reducen reenvíos accidentales
Las redes lentas no solo rompen peticiones; cambian cómo toca la gente. Cuando la pantalla se congela dos segundos, muchos usuarios asumen que no pasó nada y vuelven a tocar. Tu UI tiene que hacer que “un toque” se sienta fiable incluso con red mala.
La UI optimista es segura cuando la acción es reversible o de bajo riesgo (marcar favorito, guardar borrador). La UI confirmada es mejor para dinero, inventario, borrados irreversibles y todo lo que pueda crear duplicados.
Un buen comportamiento por defecto para acciones críticas es un estado pendiente claro. Tras el primer toque, cambia inmediatamente el botón principal a “Enviando…”, desactívalo y muestra una línea corta que explique lo que sucede.
Patrones que funcionan bien en redes inestables:
- Desactiva la acción primaria tras el tap y mantenla desactivada hasta obtener un resultado final.
- Muestra un estado “Pendiente” visible con detalles (importe, destinatario, cantidad).
- Añade una vista de “Actividad reciente” para que los usuarios confirmen lo que ya enviaron.
- Si la app va a segundo plano, conserva el estado pendiente cuando vuelvan.
- Prefiere un botón primario claro en vez de múltiples zonas de toque en la misma pantalla.
A veces la petición tiene éxito pero la respuesta se pierde. Trata eso como un resultado normal, no como un error que incite a volver a tocar. En lugar de “Falló, intenta de nuevo”, muestra “No lo sabemos aún” y ofrece un paso seguro como “Comprobar estado”. Si no puedes comprobar estado, guarda el registro pendiente localmente y di al usuario que actualizarás cuando vuelva la conexión.
Haz que “Intentar de nuevo” sea explícito y seguro. Muéstralo solo cuando puedas repetir la petición usando el mismo ID de cliente o clave de idempotencia.
Ejemplo realista: un envío de checkout inestable
Un cliente está en un tren con señal intermitente. Añade artículos al carrito y toca Pagar. La app debe ser paciente, pero también evitar crear dos pedidos.
Secuencia segura:
- La app crea un ID de intento cliente y envía el checkout con una clave de idempotencia (por ejemplo, un UUID guardado con el carrito).
- La petición espera un connect timeout claro y luego un read timeout más largo. El tren entra en un túnel y la llamada hace timeout.
- La app reintenta una vez, pero solo tras un breve retraso y solo si nunca recibió respuesta del servidor.
- El servidor recibe la segunda petición y ve la misma clave de idempotencia, por lo que devuelve el resultado original en lugar de crear otro pedido.
- La app muestra la pantalla de confirmación final cuando recibe la respuesta de éxito, aunque venga del reintento.
La caché sigue reglas estrictas. Listados de producto, opciones de envío y tablas de impuestos pueden cachearse por poco tiempo (GET). El envío del checkout (POST) nunca se cachea. Incluso si usas un caché HTTP, trátalo como ayuda de solo lectura para navegación, no como algo que “recuerde” un pago.
La prevención de duplicados mezcla red y UI. Cuando el usuario toca Pagar, el botón se desactiva y la pantalla muestra “Enviando pedido...” con una sola opción Cancelar. Si la app pierde red, pasa a “Seguimos intentando” y mantiene el mismo ID de intento. Si el usuario fuerza el cierre y vuelve, la app puede reanudar consultando el estado del pedido con ese ID en lugar de pedir que pague otra vez.
Checklist rápido y próximos pasos
Si tu app va “más o menos bien” en Wi‑Fi de oficina pero se rompe en trenes, ascensores o áreas rurales, trata esto como una condición obligatoria antes del lanzamiento. Este trabajo trata menos de código ingenioso y más de reglas claras que puedas repetir.
Checklist antes de lanzar:
- Define timeouts por tipo de endpoint (login, feed, subida, checkout) y prueba en redes con throttling y alta latencia.
- Reintenta solo donde sea verdaderamente seguro y pon límites con backoff (un par de intentos para lecturas, normalmente ninguno para escrituras).
- Añade una clave de idempotencia para cada escritura crítica (pagos, pedidos, envíos de formularios) para que un reintento o doble tap no cree duplicados.
- Haz explícitas las reglas de caché: qué puede servirse obsoleto, qué debe ser fresco y qué nunca debe cachearse.
- Haz visibles los estados: pendiente, fallado y completado deben verse diferentes, y la app debe recordar acciones completadas tras un reinicio.
Si alguno de estos puntos está en “lo decidiremos después”, acabarás con comportamiento aleatorio entre pantallas.
Próximos pasos para que perdure
Escribe una política de networking de una página: categorías de endpoints, objetivos de timeout, reglas de reintento y expectativas de caché. Hazla cumplir en un solo lugar (interceptores, una fábrica de cliente compartida o un pequeño wrapper) para que todo el equipo obtenga el mismo comportamiento por defecto.
Luego haz un ejercicio de duplicados. Elige una acción crítica (como checkout), simula un spinner congelado, fuerza el cierre de la app, activa modo avión y pulsa el botón otra vez. Si no puedes probar que es seguro, los usuarios acabarán encontrando la forma de romperlo.
Si quieres aplicar las mismas reglas en backend y clientes sin cablear todo a mano, AppMaster (appmaster.io) puede ayudar generando código backend y nativo listo para producción. Aun así, la clave es la política: define idempotencia, reintentos, caché y estados de UI una vez y aplícalos coherentemente en todo el flujo.
FAQ
Define primero qué significa “correcto” para cada pantalla y acción, sobre todo para cosas que deben ocurrir como máximo una vez (pagos, pedidos). Con las reglas claras, ajusta timeouts, reintentos, caché y estados de UI según esas reglas, en lugar de confiar en los valores por defecto de las librerías.
Los usuarios suelen ver spinners interminables, errores después de una larga espera, acciones que funcionan en un segundo intento o resultados duplicados (dos pedidos, doble cobro). A menudo esto se debe a reglas poco claras sobre reintentos y estados “pendiente vs fallado”, no solo a una señal débil.
Usa el timeout de conexión para cuánto esperar a establecer la conexión, write timeout para enviar el cuerpo de la petición (subidas) y read timeout para esperar la respuesta tras enviar. Un enfoque razonable: timeouts más cortos para lecturas de bajo riesgo y timeouts más largos para envíos críticos, y siempre un límite en la UI para que el usuario no espere indefinidamente.
Si solo puedes configurar uno, usa callTimeout para acotar la operación completa y evitar esperas “infinitas”. Encima de eso, añade connect/read/write si necesitas control más fino, especialmente para subidas y respuestas lentas.
Reintenta solo fallos temporales como caídas de conexión, problemas DNS y timeouts, y a veces códigos 502/503/504. Evita reintentar errores 4xx y no reintentes escrituras a menos que tengas protección de idempotencia, porque los reintentos pueden crear duplicados.
Usa pocos reintentos (1–3), con backoff exponencial y algo de jitter para evitar picos simultáneos. Además limita el tiempo total dedicado a reintentos para que el usuario tenga un resultado claro en lugar de un spinner que dura minutos.
Idempotencia significa que repetir la misma petición no crea un segundo resultado; así un doble tap o un reintento no produce doble cobro o doble reserva. Para acciones críticas, envía una clave de idempotencia por intento y reutilízala en reintentos para que el servidor devuelva el primer resultado en lugar de crear uno nuevo.
Genera una clave única cuando el usuario inicia la acción, guárdala localmente con un pequeño registro “pendiente” y envíala con la petición. Si reintentas o la app se reinicia, reutiliza la misma clave y vuelve a intentar o consulta el estado para no convertir una intención de usuario en dos escrituras al servidor.
Cachea solo datos que puedan estar ligeramente desactualizados y obliga a comprobaciones frescas para dinero, seguridad y decisiones finales. Para lecturas, prefiere poca frescura más revalidación y considera ETags; para escrituras, no cachees y usa no-store para respuestas sensibles.
Desactiva el botón principal tras el primer tap, muestra un estado inmediato “Enviando…” y mantén un estado pendiente visible que sobreviva al background o al reinicio. Si la respuesta puede perderse, no invites a reintentar; muestra incertidumbre (“No lo sabemos aún”) y ofrece pasos seguros como “Comprobar estado”.


