Memoria limitada y la pestaña que se recarga sola
Cuánta memoria tiene de verdad una pestaña en un móvil barato, qué hace el sistema cuando se acaba, cómo detectar que tu página fue descartada, y el patrón de persistencia que sobrevive a todo.
El usuario abre tu aplicación, empieza a rellenar un formulario largo, cambia a la aplicación de correo para copiar un dato, vuelve, y se encuentra la página recargada desde cero con todo lo escrito perdido. No ha habido ningún error, nada ha fallado y no aparecerá nada en tu registro. El sistema necesitaba memoria, tu pestaña era la candidata más gorda, y la mató. Esto ocurre constantemente en móviles de gama baja y es invisible para prácticamente toda la instrumentación estándar.
- Estimar el presupuesto de memoria real de una pestaña en un dispositivo limitado.
- Describir la escalera de reacciones del sistema ante la falta de memoria.
- Detectar en código que la página fue descartada y restaurada.
- Aplicar el patrón de persistencia por instantáneas compatible con la caché de retroceso.
Cuánta memoria hay de verdad
Un teléfono barato reciente tiene entre dos y cuatro gigabytes de memoria total. De ahí, el sistema operativo se lleva una parte importante, los servicios de fondo otra, y el resto se reparte entre las aplicaciones abiertas. Al proceso que dibuja tu pestaña le quedan, en la práctica, unos pocos cientos de megabytes antes de convertirse en candidato a morir.
Ese presupuesto se llena más deprisa de lo que la intuición sugiere:
| Consumidor | Coste típico |
|---|---|
| El propio navegador y el motor por pestaña | de 30 a 80 MB antes de tu código |
| Diez mil nodos del DOM con estilos | de 30 a 60 MB |
| Una imagen de 4.000 por 3.000 píxeles descodificada | unos 48 MB, con independencia de lo que pese el fichero |
| Un array de 100.000 objetos de diez campos | de 40 a 100 MB |
| Un lienzo a pantalla completa con su búfer doble | de 10 a 30 MB |
| Una biblioteca grande ya compilada | de 5 a 30 MB |
La fila de la imagen merece énfasis porque es la que más sorprende. El tamaño en memoria de una imagen no tiene nada que ver con el tamaño del fichero. Un archivo de 800 kilobytes muy comprimido, al descodificarse, ocupa cuatro bytes por píxel: doce millones de píxeles son cuarenta y ocho megabytes de memoria viva mientras esté en pantalla. Una galería con seis imágenes de ese tamaño se come casi trescientos megabytes en un aparato que tenía unos cientos. Servir las imágenes al tamaño en el que se van a mostrar no es solo una optimización de descarga: es, en móvil, sobre todo una optimización de memoria.
Para saber dónde estás, measureUserAgentSpecificMemory() es la vía estándar y está tratada en medir memoria en producción. Lo que aporta esta lección es qué hacer con el número: en móvil, el umbral de preocupación es mucho más bajo de lo que parece razonable desde un portátil. Doscientos megabytes en un escritorio no son nada; doscientos megabytes en un móvil de dos gigabytes te ponen en la lista de candidatos.
La escalera de reacciones
El sistema no mata la pestaña de golpe. Hay una progresión, y cada escalón se puede detectar y aprovechar.
Uno: congelación en segundo plano. Con la página oculta un rato, el navegador congela el proceso: los temporizadores dejan de dispararse y la ejecución se detiene. En motores basados en Chromium hay eventos explícitos, freeze y resume, para enterarse. Es reversible y no se pierde nada.
Dos: descarte. El navegador destruye el proceso de la pestaña pero conserva su entrada en la lista, con el título y la URL. Cuando el usuario vuelve, se produce una navegación completa a esa URL: se recarga la página desde cero, el estado en memoria ha desaparecido, y document.wasDiscarded vale true.
Tres: el sistema mata el navegador entero. Ocurre en aparatos muy justos y con presión externa. Al volver, el navegador restaura las pestañas y todas se recargan.
Cuatro: agotamiento dentro de la pestaña. Tu propio código pide memoria y no hay. El proceso muere y el usuario ve una página de error del navegador o, en móvil, a menudo una recarga automática.
La detección de los dos primeros escalones es directa y cabe en pocas líneas:
// Restauracion tras un descarte.
if (document.wasDiscarded) {
restaurarDesdeInstantanea();
}
// Congelacion y reanudacion, donde exista.
document.addEventListener('freeze', () => {
guardarInstantanea(); // Ultima oportunidad garantizada.
});
document.addEventListener('resume', () => {
// El proceso sobrevivio: no hace falta restaurar nada.
});
// Universal y el que de verdad importa.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') guardarInstantanea();
});
La regla operativa que se deduce: el momento fiable para persistir estado es cuando la página pasa a oculta, no cuando se descarga. El evento de descarga no se dispara de forma fiable en móvil —el sistema mata el proceso sin avisar— y además tiene un efecto secundario grave, que veremos ahora.
Persistencia por instantáneas
El patrón que sobrevive a los cuatro escalones consiste en guardar una instantánea del estado que merece la pena conservar cada vez que la página se oculta, y restaurarla al arrancar si detectas que hubo descarte.
const CLAVE = 'instantanea-formulario';
function guardarInstantanea() {
try {
const estado = {
version: 2,
guardadoEn: Date.now(),
ruta: location.pathname,
campos: Object.fromEntries(
new FormData(document.querySelector('#formulario')).entries()
),
desplazamiento: window.scrollY,
};
// Sincrono a proposito: en este instante puede no haber otro despues.
localStorage.setItem(CLAVE, JSON.stringify(estado));
} catch {
// Cuota llena o almacenamiento bloqueado: no es motivo para romper nada.
}
}
function restaurarDesdeInstantanea() {
let estado;
try {
estado = JSON.parse(localStorage.getItem(CLAVE) || 'null');
} catch {
return;
}
if (!estado || estado.version !== 2) return;
if (estado.ruta !== location.pathname) return;
// Mas de un dia: probablemente ya no interesa.
if (Date.now() - estado.guardadoEn > 86400000) return;
const formulario = document.querySelector('#formulario');
for (const [nombre, valor] of Object.entries(estado.campos)) {
const campo = formulario.elements[nombre];
if (campo) campo.value = valor;
}
window.scrollTo(0, estado.desplazamiento);
avisar('Hemos recuperado lo que estabas escribiendo.');
}
Cinco detalles que hacen que esto funcione en producción. El número de versión permite cambiar el formato sin romper a quien tenga una instantánea vieja. La escritura es síncrona porque en ese instante puede no haber un instante siguiente. Todo va envuelto en try porque el almacenamiento puede estar lleno o bloqueado por la configuración del usuario, y ninguna de las dos cosas justifica romper la página. La caducidad evita restaurar algo de hace un mes. Y el aviso al usuario es imprescindible: restaurar contenido en silencio confunde, porque nadie entiende por qué hay texto que no ha escrito ahora.
La caché de retroceso y por qué se pierde
Hay un mecanismo del navegador que evita muchas de estas recargas y que se desactiva por accidente con enorme frecuencia. La caché de retroceso guarda la página entera —memoria, estado de JavaScript, posición de desplazamiento— congelada al navegar fuera, y la restaura íntegra al volver atrás. Cuando funciona, la vuelta atrás es instantánea y no se pierde nada. Cuando no, se recarga todo.
Las cuatro causas más frecuentes de que no funcione:
Un escuchador de unload. Basta con registrarlo, aunque esté vacío, para que la página deje de ser elegible. Es la causa número uno, y suele venir de una biblioteca de analítica antigua. Sustituirlo por visibilitychange con estado oculto es estrictamente mejor: además de no romper la caché, se dispara de forma fiable en móvil, cosa que unload no hace.
Cache-Control: no-store en el documento. Excluye la página de esta caché. Si lo estás usando por costumbre en una página que no tiene datos sensibles, quítalo; para lo que probablemente querías, no-cache es suficiente, como se vio en qué se puede cachear en el borde.
Conexiones abiertas de ciertos tipos en el momento de navegar fuera, que impiden congelar la página limpiamente. Cerrarlas al ocultarse y reabrirlas al mostrarse resuelve las dos cosas a la vez.
Un beforeunload mal usado. Reduce la elegibilidad en algunos motores. Úsalo solo cuando de verdad haya cambios sin guardar, registrándolo y quitándolo dinámicamente, no de forma permanente.
Y la comprobación de que ha funcionado:
window.addEventListener('pageshow', (evento) => {
if (evento.persisted) {
// Ha vuelto de la cache de retroceso: no ha habido carga.
// Refresca solo lo que caduca, como relojes o contadores.
refrescarDatosVolatiles();
}
});
Las herramientas de desarrollo de los navegadores basados en Chromium tienen un panel específico que prueba la elegibilidad y, cuando falla, dice exactamente qué motivo lo impidió. Es una de las comprobaciones con mejor relación entre esfuerzo y beneficio que existen: lleva dos minutos y a menudo devuelve una navegación instantánea que llevabas años perdiendo.
El modelo mental que traemos del escritorio es que una sesión empieza cuando el usuario abre la página y termina cuando la cierra, y que entre esos dos momentos hay una continuidad garantizada donde el estado en memoria persiste. Todo lo que escribimos asume eso, y en un portátil con dieciséis gigabytes es casi verdad. En un móvil de gama baja es directamente falso, y la diferencia no es de grado. La sesión de tu página se interrumpe cada vez que el usuario contesta una llamada, cada vez que abre la cámara, cada vez que cambia a la aplicación de mensajería para copiar un código, y en cada uno de esos momentos el sistema evalúa si tu proceso merece seguir vivo. La respuesta depende de cuánta memoria consumes, de cuántas otras aplicaciones haya, y de nada que tú controles. Es decir, el ciclo de vida de tu página en móvil se parece mucho más al de un proceso de servidor que puede ser terminado en cualquier momento que al de un programa de escritorio, y la disciplina que hace falta es exactamente la misma que se aplica en infraestructura desde hace años: asume la terminación, persiste el estado importante en cuanto se produce y no cuando vayas a salir, haz que el arranque sea capaz de reconstruir la situación, y no dependas nunca de un evento de despedida. Hay una consecuencia de esto que es la que de verdad cambia productos, y no es técnica sino de diseño: cuanto más caro sea perder el estado, más grave es este problema, y hay decisiones de producto que lo multiplican sin que nadie lo haya evaluado. Un formulario de doce pasos que solo guarda al final es una máquina de generar abandono en móvil, y el abandono se atribuirá a la longitud del formulario, que también, pero sobre todo a que el sistema mató la pestaña en el paso nueve y la persona no volvió a empezar. El carrito que vive solo en memoria, el filtro complejo que el usuario tardó dos minutos en configurar, el borrador del mensaje largo: todos son estado caro cuya pérdida se manifiesta en tus métricas de negocio y jamás en tus métricas de error. Y aquí está el detalle que hace esto tan difícil de descubrir: no genera ninguna señal. No hay excepción, no hay petición fallida, no hay entrada en el registro. Solo hay un usuario que no vuelve. La forma de sacarlo a la luz es medir explícitamente lo que nadie mide: cuenta cuántas de tus cargas de página tienen document.wasDiscarded a verdadero, segmentado por dispositivo, y ponlo en el panel. En móvil de gama baja el número asusta la primera vez, y a partir de ese momento la persistencia por instantáneas deja de parecer una precaución exagerada.
- Mide la memoria de tu página en la ruta más pesada y compárala con un presupuesto de doscientos megabytes.
- Busca las imágenes que se descodifican a un tamaño mucho mayor del que se muestran. Calcula sus megabytes reales.
- Instrumenta
document.wasDiscardedy mira qué porcentaje de tus cargas son restauraciones, segmentado por clase de dispositivo. - Comprueba la elegibilidad para la caché de retroceso con el panel del navegador y arregla lo que la bloquee. Empieza buscando
unload. - Implementa la persistencia por instantáneas en tu formulario más largo y pruébala forzando el descarte de la pestaña.