El coste de las polyfills y de los transpilados que ya no hacen falta
Cuánto pesan de verdad las polyfills que arrastra una configuración heredada, por qué el transpilado a sintaxis antigua duplica el tamaño y ralentiza la ejecución, y cómo auditar qué estás enviando y a quién.
La configuración de compilación de un proyecto es un sedimento: cada capa se añadió por una razón válida en su momento y casi ninguna se retira cuando la razón desaparece. El resultado, en un proyecto de tres o cuatro años, suele ser un bundle que envía polyfills para navegadores que ya no existen y transpila sintaxis moderna a equivalentes de 2015 que todos los motores entienden nativamente desde hace ocho años. El coste combinado está entre el 15 y el 40 por ciento del peso, y se recupera cambiando una línea de configuración.
- Distinguir polyfill de transformación de sintaxis y saber qué cuesta cada una.
- Auditar qué polyfills incluye tu bundle y para qué navegadores.
- Cuantificar la expansión de código que produce transpilar sintaxis moderna.
- Actualizar el objetivo de navegadores con una decisión defendible.
Polyfill y transformación no son lo mismo
Son dos mecanismos distintos con costes distintos, y confundirlos impide razonar sobre ellos.
Una polyfill implementa una API que falta. Array.prototype.at, Object.hasOwn, structuredClone, Promise.allSettled: son funciones que o están en el motor o no están, y si no están se pueden añadir en tiempo de ejecución. Una polyfill es código adicional que se ejecuta y se queda.
Una transformación de sintaxis reescribe tu código en una forma equivalente que un motor antiguo pueda parsear. El encadenamiento opcional, la desestructuración, async/await, los campos privados de clase: son formas gramaticales, y si el motor no las entiende, el fichero entero falla al parsearse. No se pueden polirrellenar; hay que reescribirlas.
La diferencia práctica: una polyfill que no hace falta es peso muerto pero no toca tu código. Una transformación innecesaria cambia todo tu código y afecta al tamaño, al parseo y a la velocidad de ejecución.
Lo que cuestan las polyfills
Los números concretos de lo que se suele encontrar en una configuración heredada:
| Conjunto de polyfills | Peso comprimido | Para qué |
|---|---|---|
| Biblioteca de compatibilidad completa importada entera | 90-140 KB | Todo, incluidos navegadores de 2012 |
Conjunto automático con objetivo > 0.2% |
25-50 KB | Navegadores antiguos residuales |
| Conjunto automático con objetivo de navegadores modernos | 0-4 KB | Casi nada, porque casi nada falta |
Polyfill de fetch |
4 KB | Navegadores anteriores a 2017 |
Polyfill de IntersectionObserver |
6 KB | Navegadores anteriores a 2019 |
Polyfill de Promise |
3 KB | Navegadores anteriores a 2015 |
Las tres últimas filas son ejemplos de un patrón que hay que buscar activamente: polyfills de APIs que llevan años en todos los motores. Están en el bundle porque alguien las añadió cuando hacían falta y nadie las quitó. Buscar sus nombres en el árbol de dependencias es un ejercicio de diez minutos que suele encontrar tres o cuatro.
# Polyfills clasicos que ya no hacen falta en ningun navegador con soporte
npm ls whatwg-fetch isomorphic-fetch es6-promise promise-polyfill \
intersection-observer resize-observer-polyfill \
object-assign babel-polyfill core-js@2 2>/dev/null
La otra mitad del problema es la inyección automática. Las herramientas modernas insertan solo las polyfills que tu código necesita, según tu objetivo de navegadores. Eso está bien y es lo correcto, con dos matices que producen sorpresas.
Matiz uno: la inyección se hace por uso sintáctico, no por análisis de flujo. Si en algún sitio escribes .includes(, la herramienta puede inyectar la polyfill de Array.prototype.includes y la de String.prototype.includes, porque no sabe de qué tipo es el receptor. La suma de esos falsos positivos es real.
Matiz dos: el objetivo de navegadores lo controla una lista que a menudo nadie ha mirado. Si tu proyecto tiene un browserslist con > 0.2% o, peor, defaults, estás sirviendo compatibilidad para navegadores que representan una fracción de tu tráfico o directamente cero. La comprobación es inmediata:
npx browserslist
# Y cuanto trafico representan de verdad, con tus propios datos:
npx browserslist --coverage
Ver la lista resultante es normalmente la parte más convincente del ejercicio: aparecen versiones de navegadores que ninguno de tus usuarios tiene.
Lo que cuesta transpilar sintaxis
Aquí las cifras son mayores y menos conocidas. Transpilar sintaxis moderna a la de 2015 no solo añade ayudantes: reescribe el código en formas más largas y más lentas.
Los tres casos que más pesan:
async/await a generadores y máquinas de estado. Es la transformación más cara con diferencia. Una función asíncrona de diez líneas se convierte en una máquina de estados con un switch, un objeto de contexto y llamadas a un ayudante de tiempo de ejecución. El código resultante puede ser tres o cuatro veces más largo y ejecutarse notablemente más lento, porque el motor pierde la capacidad de optimizar la función asíncrona nativa.
Clases a funciones con prototipos. Cada clase genera funciones auxiliares para la herencia, para la comprobación de instancia, para los campos. En una base de código con doscientas clases, esos ayudantes se repiten o se importan y suman.
Desestructuración y propagación a bucles. Un const { a, b, ...resto } = obj se convierte en varias líneas con un ayudante que copia propiedades. Inocuo una vez, medible dos mil veces.
El efecto agregado, medido sobre una aplicación real al cambiar el objetivo de compilación de sintaxis de 2015 a sintaxis moderna, está típicamente entre el 10 y el 25 por ciento del tamaño del bundle, y una parte proporcional del tiempo de ejecución. Sin tocar una línea de código propio.
// El fuente que escribes
async function cargar(id) {
const { datos, meta } = await pedir(`/api/${id}`);
return { ...meta, total: datos.length };
}
// Aproximadamente lo que produce un objetivo antiguo: mas largo y mas lento
function cargar(id) {
return _asyncToGenerator(function* () {
var _yield = yield pedir("/api/".concat(id)),
datos = _yield.datos,
meta = _yield.meta;
return _objectSpread(_objectSpread({}, meta), {}, { total: datos.length });
})();
}
El fragmento de arriba es ilustrativo, no literal —cada versión de cada herramienta genera algo ligeramente distinto— pero la forma es exactamente esa, y el ayudante _asyncToGenerator con su tiempo de ejecución de regenerador puede añadir por sí solo entre 5 y 15 KB al bundle.
La solución elegante al problema es servir dos bundles: uno moderno para navegadores modernos y uno antiguo para los demás, seleccionados con el par de atributos type="module" y nomodule. Es una técnica correcta, funciona, y en 2026 casi nunca merece la pena. Conviene entender por qué, porque es un buen ejemplo de optimización que envejeció.
El coste de mantener dos salidas. Dos compilaciones, dos veces el tiempo de integración continua, dos artefactos que cachear y purgar, y dos superficies donde puede aparecer un fallo que solo se reproduce en uno de los dos caminos. Ese último punto es el caro: un fallo que solo ocurre en el bundle antiguo es un fallo que tu equipo no reproduce nunca.
El beneficio se ha evaporado. La técnica se popularizó cuando el reparto era del 85 por ciento moderno y el 15 antiguo, y ahorrar un 20 por ciento a ese 85 era mucho. Hoy el reparto de navegadores que soportan módulos ES, async/await, clases y encadenamiento opcional está por encima del 97 por ciento en casi cualquier audiencia. Estás manteniendo una infraestructura doble para el 2 por ciento.
Y hay un coste de descarga que casi nadie menciona: algunos navegadores antiguos con soporte parcial de módulos descargan los dos bundles. El fallo es conocido, afecta a versiones concretas de Safari y de Edge heredado, y significa que una parte de los usuarios a los que la técnica pretendía ayudar pagan el doble.
La alternativa honesta es subir el suelo y decirlo. Fija un objetivo de navegadores explícito y reciente, compila solo para él, y sirve a los usuarios por debajo de ese suelo una página estática de aviso en lugar de una aplicación rota. Es más respetuoso que servirles una versión degradada que va a fallar de todas formas por alguna API que sí falta.
{
"browserslist": [
"chrome >= 111",
"edge >= 111",
"firefox >= 113",
"safari >= 16.4",
"not dead"
]
}Ese conjunto concreto está elegido con criterio: son las versiones a partir de las cuales los cuatro motores comparten un conjunto grande y estable de sintaxis y APIs, incluidos los campos privados de clase, structuredClone, Array.prototype.at y el encadenamiento opcional con asignación lógica. Compilar contra él elimina prácticamente todas las polyfills y todas las transformaciones caras.
Y la comprobación que hay que hacer antes de fijarlo, que es de dos minutos: mira en tu analítica el reparto real de versiones de navegador de tus usuarios de los últimos noventa días. Si el suelo que propones deja fuera al 0,4 por ciento, es una decisión fácil. Si deja fuera al 6 por ciento porque tienes una audiencia corporativa con navegadores gestionados, es otra conversación, y entonces sí puede tener sentido el bundle diferencial. La decisión sale de tus datos, no del sector.
La auditoría, en veinte minutos
Cuatro comprobaciones, por orden.
Uno: mira tu browserslist y ejecuta npx browserslist para ver la lista expandida. Si aparecen navegadores que no reconoces o versiones de hace ocho años, ahí está el problema.
Dos: busca importaciones de bibliotecas de compatibilidad en tu código. Un import 'core-js' o similar en el punto de entrada arrastra el conjunto completo, ignorando cualquier configuración de inyección automática. Es el fallo más común.
Tres: busca el tiempo de ejecución del transpilador en el informe del bundle. Los ayudantes suelen aparecer con nombres reconocibles y agrupados. Si suman más de 10 KB comprimidos, tu objetivo de compilación es demasiado antiguo.
Cuatro: cambia el objetivo, compila y compara. Es la medición que decide, y es la que hay que llevar a la discusión:
npm run build && node scripts/tamanos.mjs > /tmp/antes.txt
# edita browserslist al objetivo moderno
npm run build && node scripts/tamanos.mjs > /tmp/despues.txt
diff -u /tmp/antes.txt /tmp/despues.txt
Ejecuta las pruebas después del cambio y prueba manualmente en la versión más antigua del suelo que has fijado. Es un cambio de bajo riesgo, no de riesgo cero: si tu código usa una API que tu objetivo antiguo estaba polirrellenando y el nuevo no, se rompe en tiempo de ejecución y no en compilación.
Ejecuta las cuatro comprobaciones sobre tu proyecto y anota el ahorro en kilobytes comprimidos de cambiar el objetivo. Después mira en tu analítica qué porcentaje de tus usuarios queda por debajo del suelo que has propuesto. Si ese porcentaje es menor que el 1 por ciento, lleva las dos cifras juntas a tu equipo: el ahorro y el coste. Es una de las pocas decisiones de rendimiento que se toman en una reunión de cinco minutos.