El polyfill y su coste real
Qué hace por dentro el polyfill oficial de ScrollTimeline, cuánto pesa, qué rendimiento sacrifica y en qué casos concretos merece la pena cargarlo.
Existe un polyfill oficial, mantenido por el mismo ingeniero que llevó la especificación en Chromium, y funciona. Esa frase suele bastar para que alguien lo añada al proyecto sin más análisis, y ahí empieza el problema: un polyfill de animaciones no es como un polyfill de Array.prototype.at. No añade una función que faltaba; sustituye un subsistema que el navegador ejecutaba fuera del hilo principal por una emulación que corre dentro. Merece la pena saber exactamente qué estás comprando.
- Describir qué hace el polyfill por dentro y por qué necesita un parser de CSS.
- Cuantificar el coste en bytes y el coste en tiempo de ejecución.
- Cargarlo de forma que no afecte a los navegadores con soporte nativo.
- Decidir con criterio si compensa en un proyecto concreto.
Qué hace por dentro
El polyfill se distribuye como el paquete scroll-timeline-polyfill y su fichero de distribución pesa unos 60 KB sin comprimir. Ese número por sí solo no dice gran cosa; lo interesante es en qué se gastan.
El paquete tiene tres piezas grandes y ninguna es accesoria:
Un tokenizador y un parser de CSS. El polyfill no puede pedirle al navegador que le diga qué hay en animation-timeline, porque el navegador no reconoce esa propiedad y la descarta antes de que nadie pueda leerla. Así que el polyfill recorre las hojas de estilo del documento, las vuelve a parsear con su propio tokenizador y extrae las declaraciones que el motor tiró. Son unos 25 KB solo del parser de CSS, más el tokenizador.
Un proxy sobre Animation y Element.animate. Cuando encuentra una animación con timeline de scroll, la crea como animación normal, la pausa, y le va asignando currentTime a mano en función de la posición del scroller. Todo el sistema de progreso está reimplementado en JavaScript.
Un motor de valores numéricos y simplificación de calc(). Porque animation-range: entry calc(100% + 200px) tiene que resolverse contra un rango que el polyfill calcula por su cuenta, y para eso hace falta reimplementar la aritmética de valores de CSS.
Es una pieza de ingeniería notable y la conclusión honesta es que hace lo que promete. La pregunta es a qué precio.
El coste que no está en los bytes
Los 60 KB son lo de menos. El coste real tiene tres componentes y ninguno aparece en el informe del bundler.
La animación pasa al hilo principal. Es el coste dominante. Una animación de scroll nativa sobre transform u opacity puede ejecutarse en el hilo del compositor: sigue siendo fluida aunque el hilo principal esté ocupado. La versión emulada asigna currentTime desde JavaScript, así que por construcción depende del hilo principal en cada frame. Si tu página tiene hidratación, o un observador caro, o un parseo de JSON grande, la animación emulada se entrecorta justo cuando la nativa no lo haría.
El parseo de CSS ocurre en tiempo de carga. El polyfill tiene que recorrer y volver a parsear las hojas de estilo antes de poder hacer nada. En un sitio con CSS pequeño es imperceptible; en un sitio con un framework de utilidades y decenas de miles de reglas, es una tarea larga en el peor momento posible, justo cuando el usuario está esperando la primera pintura.
La cobertura no es total. El propio proyecto no promete paridad completa con la especificación. Las partes más recientes o menos usadas —combinaciones de timeline-scope, algunos casos de rangos nombrados con longitudes, interacciones con @layer— pueden comportarse de forma distinta o no funcionar. Añadir el polyfill no significa “ahora es igual en todas partes”; significa “ahora hay tres comportamientos que probar en lugar de dos”.
Recapitula lo que estás decidiendo. Sin polyfill, el usuario de Firefox ve el sitio sin unas animaciones decorativas: un fallo cosmético, acotado, que no puede empeorar. Con polyfill, ese mismo usuario descarga 60 KB adicionales, ejecuta un parseo completo de tu CSS durante la carga, y recibe animaciones que corren en el hilo principal en un navegador que —esto es lo que casi nadie considera— no ha sido optimizado para ese patrón de trabajo, porque nadie en Mozilla ha perfilado la ruta de asignar currentTime sesenta veces por segundo desde JavaScript. Has cambiado un fallo cosmético garantizado por un riesgo de rendimiento probable. En un móvil de gama media con la CPU saturada, el resultado no es “las animaciones funcionan”: es “las animaciones van a tirones y además la página tardó más en cargar”, que es objetivamente peor que no tenerlas. Y hay un segundo efecto, de mantenimiento, que es el que acaba doliendo: con el polyfill en el proyecto, el fallback deja de ejercitarse. Nadie vuelve a comprobar qué se ve sin las animaciones, porque aparentemente siempre están. El día que el polyfill falle con una versión nueva del navegador, o alguien lo quite por peso, el sitio se cae al camino no probado. Un fallback que no se ejecuta nunca es un fallback que no existe. La recomendación, con todo lo anterior sobre la mesa: para animación decorativa, no cargues el polyfill; escribe bien la mejora progresiva y déjalo estar.
Cuándo sí
Hay casos legítimos y conviene reconocerlos, porque la recomendación anterior no es una prohibición.
Cuando la animación es el producto. Un sitio de portfolio, una landing de campaña, una pieza editorial cuyo valor es la experiencia de scroll. Ahí la animación no es decoración: es el contenido, y no mostrarla equivale a no mostrar el producto. Si además el sitio es corto y el CSS pequeño, los dos costes principales se diluyen.
Cuando la alternativa es peor. Si el equipo va a implementar el efecto con listeners de scroll para cubrir Firefox, el polyfill es mejor que ese código: está mejor probado, es más correcto y no habrá que mantenerlo. El polyfill no compite contra “nada”, compite contra lo que ese equipo habría escrito.
Cuando controlas el entorno. Una aplicación interna, un quiosco, un panel embebido. Si sabes que los usuarios están en un navegador concreto y con una máquina concreta, el análisis de riesgo cambia por completo.
Cuando es temporal y está anotado. Si lo cargas con la intención explícita de quitarlo cuando Gecko implemente la especificación, con un comentario que lo diga y una tarea que lo recuerde, el coste es finito y aceptable.
Cómo cargarlo sin castigar a nadie
Si decides cargarlo, la única forma correcta es condicional y dinámica, para que el navegador con soporte nativo no descargue ni un byte:
if (!CSS.supports('animation-timeline', 'scroll()')) {
await import('scroll-timeline-polyfill');
}
Tres precisiones que marcan la diferencia entre hacerlo bien y hacerlo a medias.
La detección tiene que ser CSS.supports(), no 'ScrollTimeline' in window. Ya vimos por qué: el polyfill define ese constructor, así que la comprobación se invalida a sí misma en cuanto se ha cargado una vez.
Tiene que ser una importación dinámica, no un import estático arriba del fichero, porque un import estático entra en el grafo de módulos y se descarga siempre. Con import() el bundler genera un fragmento aparte que solo se pide cuando la condición se cumple.
Tiene que cargarse antes de que importe. El polyfill parsea las hojas de estilo al inicializarse; si lo cargas después de que la página lleve tiempo pintada, verás el estado sin animar y luego un salto. Si es el camino que eliges, ese fragmento tiene que ir pronto, lo que a su vez significa que el navegador sin soporte paga una petición extra en la ruta crítica. Es otro coste que hay que contar.
Y una última nota para cerrar el análisis: mide en lugar de suponer. Cargar el polyfill, abrir el panel de rendimiento en un perfil de CPU ralentizada y comparar los frames con y sin él lleva quince minutos y sustituye toda esta discusión por un número. Si en tu sitio concreto el impacto es despreciable, adelante. Lo que no vale es añadirlo sin mirar.