Saltar al contenido principal
Desarrollo 8 min

Optimización JavaScript: Rendimiento Web | Blog SEO Ighenatt

Técnicas avanzadas de optimización JavaScript para diseño web profesional. Mejora velocidad de carga, Core Web Vitals, rendimiento y experiencia de usuario e...

EG

Elu Gonzalez

Autor

¿Cómo optimizar JavaScript para mejorar el rendimiento web y las Core Web Vitals?

La optimización de JavaScript sigue tres estrategias principales: reducción del bundle (code splitting + tree shaking reducen el tamaño un 30-70%), optimización de la carga (async/defer para scripts no críticos) y liberación del hilo principal (Web Workers para tareas >50ms). Según el HTTP Archive 2023, la mediana de JavaScript por página es de 509KB en móvil, siendo el factor principal de bloqueo del hilo principal y del INP.

Ideas clave

  • La mediana de JavaScript por página web es 509KB en móvil (HTTP Archive 2023); sitios con >1MB de JS tienen un INP promedio 3x mayor al umbral de Google.
  • Code splitting en Webpack/Vite divide el bundle en chunks cargados bajo demanda; implementado correctamente puede reducir el JavaScript inicial en un 40-60%.
  • Los Long Tasks (tareas de >50ms en el hilo principal) son la causa principal de un INP deficiente; Web Workers permiten mover procesamiento intensivo fuera del hilo principal.
  • Tree shaking elimina código no utilizado del bundle final; requiere módulos ES (import/export), no funciona con CommonJS (require). Puede reducir bundles un 20-40%.
  • El patrón PRPL (Push, Render, Pre-cache, Lazy-load) de Google optimiza la entrega progresiva de JavaScript en Progressive Web Apps para redes lentas.

JavaScript es el recurso más caro de la web. No solo en bytes: en tiempo de CPU. Descargarlo es solo el primer paso — luego viene parsearlo, compilarlo y ejecutarlo, todo en el hilo principal, todo compitiendo con el resto de la página.

Hemos auditado sitios donde el JS consumía más del 60% del tiempo de hilo principal en mobile. En esos casos, mejorar el LCP o el INP sin tocar el JavaScript es trabajar contra la corriente. La buena noticia es que los patrones de problema se repiten, y las soluciones también.

Fundamentos de optimización JavaScript

JavaScript es el recurso con mayor coste de procesamiento en la web: no solo hay que descargarlo, sino parsearlo, compilarlo y ejecutarlo. Según el HTTP Archive 2023, la mediana de JavaScript por página en móvil es de 509KB, lo que convierte al JS en el factor principal de bloqueo del hilo principal y, por extensión, de un INP deficiente.

Impacto de JavaScript en el rendimiento y Core Web Vitals

Cómo afecta la velocidad de carga

Los archivos JavaScript bloquean el renderizado por defecto: cuando el navegador encuentra un <script> sin async o defer, detiene el procesamiento del HTML hasta descargarlo, parsearlo y ejecutarlo. En dispositivos móviles con CPU limitada, este coste es mayor que en desktop.

El JS mal optimizado también provoca fugas de memoria que degradan el rendimiento progresivamente durante la sesión, hasta llegar a bloquear el navegador en dispositivos de gama baja.

Las métricas Core Web Vitals directamente afectadas:

  • INP (Interaction to Next Paint): la nueva métrica de interactividad. Los Long Tasks (>50ms en el hilo principal) son su causa principal.
  • TBT (Total Blocking Time): tiempo total de bloqueo del hilo principal. Correlaciona directamente con el INP en datos de laboratorio.
  • TTI (Time to Interactive): tiempo hasta que la página responde a interacciones. JS pesado lo retrasa en móviles.
  • FID (First Input Delay): reemplazado por INP en marzo de 2024, pero sigue apareciendo en herramientas de análisis históricas.
Comparativa de rendimiento: JavaScript optimizado vs no optimizado
MétricaSin optimizarCon técnicas básicasCon optimización avanzada
Tamaño del bundle1.2MB780KB450KB
Tiempo de carga4.2s2.8s1.5s
Total Blocking Time850ms340ms120ms
Memory usage82MB65MB48MB

Estrategias de carga: code splitting y lazy loading

Carga eficiente con async y defer

Los atributos async y defer controlan cuándo se ejecuta el script en relación al parsing del HTML:

<!-- Bloquea el análisis HTML -->
<script src="script.js"></script>

<!-- No bloquea el análisis HTML, se ejecuta cuando está listo -->
<script async src="script.js"></script>

<!-- No bloquea el análisis HTML, espera a que el HTML esté completamente analizado -->
<script defer src="script.js"></script>
Comparación entre atributos async y defer
Característicaasyncdefer
Bloquea el parseo HTMLNoNo
Orden de ejecuciónNo garantizadoMismo orden del documento
Momento de ejecuciónAl terminar descargaDespués del parseo HTML
Ideal paraArchivos independientesArchivos dependientes del DOM

Carga dinámica de código

Carga archivos JS solo cuando sean necesarios:

function loadScript(url) {
  return new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = url;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
  });
}

// Cargar solo cuando el usuario interactúa
document.querySelector('.boton-feature').addEventListener('click', async () => {
  try {
    await loadScript('/ruta/a/feature-script.js');
    initFeature(); // Función definida en el script cargado
  } catch (error) {
    console.error('Error al cargar el script:', error);
  }
});

Implementación de code splitting

El code splitting consiste en dividir tu código en chunks más pequeños que pueden cargarse bajo demanda.

Con Webpack

// Importación dinámica
const boton = document.querySelector('#cargar-componente');
boton.addEventListener('click', () => {
  import(/* webpackChunkName: "componente" */ './componente.js')
    .then(module => {
      const componente = module.default;
      componente.inicializar();
    })
    .catch(error => {
      console.error('Error al cargar el componente', error);
    });
});

Con React

import React, { lazy, Suspense } from 'react';

// Importación diferida del componente pesado
const ComponentePesado = lazy(() => import('./ComponentePesado'));

function App() {
  return (
    <div>
      <h1>Mi Aplicación</h1>
      <Suspense fallback={<div>Cargando...</div>}>
        <ComponentePesado />
      </Suspense>
    </div>
  );
}

Reducción del bundle: minificación y compresión

Minificación del código

La minificación elimina espacios, comentarios y acorta nombres de variables para reducir el tamaño del archivo sin cambiar su comportamiento. Las herramientas más usadas son Terser (estándar actual para JS moderno), UglifyJS (para código legacy) y babel-minify (si ya usas Babel en el pipeline).

Ejemplo con webpack:

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  // ...
  optimization: {
    minimize: true,
    minimizer: [new TerserPlugin({
      terserOptions: {
        compress: {
          drop_console: true, // Elimina console.log en producción
        },
      },
    })],
  },
};

Compresión Gzip y Brotli

La compresión reduce aún más el tamaño de transferencia:

  • Gzip: Ampliamente soportada, buena relación compresión/CPU
  • Brotli: Mayor compresión que Gzip, ideal para texto/JavaScript

Configuración en servidor Apache:

# Habilitar mod_deflate para compresión Gzip
<IfModule mod_deflate.c>
  AddOutputFilterByType DEFLATE text/html text/plain text/css application/javascript
</IfModule>

# Para Brotli (requiere mod_brotli)
<IfModule mod_brotli.c>
  AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css application/javascript
</IfModule>

Tree shaking y análisis del bundle

El tree shaking elimina código no utilizado de tus bundles.

// utils.js
export function funcion1() { /* ... */ }
export function funcion2() { /* ... */ }

// main.js - Solo funcion1 será incluida en el bundle final
import { funcion1 } from './utils';
funcion1();

Análisis del bundle

Herramientas para analizar el contenido de tus bundles:

  • webpack-bundle-analyzer
  • source-map-explorer
# Instalar source-map-explorer
npm install --save-dev source-map-explorer

# Analizar un bundle
npx source-map-explorer dist/main.js

Optimización de ejecución: hilo principal y eventos

Técnicas para liberar el hilo principal

El hilo principal del navegador gestiona tanto el renderizado como la ejecución de JavaScript. Cuando una tarea ocupa el hilo más de 50ms, el navegador no puede responder a las interacciones del usuario — ese es el origen del INP alto.

Dos técnicas tienen el mayor impacto:

Descomponer tareas largas — dividir loops extensos en lotes procesados con setTimeout(0) para ceder el hilo entre iteraciones:

// En lugar de este código bloqueante
function procesarDatosGrandes(datos) {
  const resultados = [];
  for (let i = 0; i < datos.length; i++) {
    resultados.push(procesarItem(datos[i]));
  }
  return resultados;
}

// Usar este enfoque no bloqueante
function procesarDatosGrandes(datos, callback) {
  const resultados = [];
  let i = 0;
  
  function procesarLote() {
    const inicio = performance.now();
    
    // Procesar hasta llegar al tiempo límite
    while (i < datos.length && performance.now() - inicio < 50) {
      resultados.push(procesarItem(datos[i]));
      i++;
    }
    
    // Si hay más datos, programar siguiente lote
    if (i < datos.length) {
      setTimeout(procesarLote, 0);
    } else {
      callback(resultados);
    }
  }
  
  procesarLote();
}

Web Workers para tareas intensivas — mover procesamiento pesado a un hilo separado para que el hilo principal quede libre:

// main.js
const worker = new Worker('worker.js');

worker.addEventListener('message', function(e) {
  console.log('Resultado: ', e.data);
});

worker.postMessage({datos: arrayGrande, operacion: 'procesar'});

// worker.js
self.addEventListener('message', function(e) {
  if (e.data.operacion === 'procesar') {
    const resultado = procesarDatos(e.data.datos);
    self.postMessage(resultado);
  }
});

function procesarDatos(datos) {
  // Operación intensiva que no bloquea la UI
  return datos.map(x => x * x).filter(x => x > 100);
}

Debounce, throttle y delegación

Los manejadores de eventos mal implementados pueden causar problemas de rendimiento:

Debounce y throttle para eventos frecuentes:

// Función debounce - Ejecuta la función después de un tiempo desde la última llamada
function debounce(func, wait) {
  let timeout;
  return function executedFunction(...args) {
    const later = () => {
      clearTimeout(timeout);
      func(...args);
    };
    clearTimeout(timeout);
    timeout = setTimeout(later, wait);
  };
}

// Función throttle - Limita la ejecución a una vez cada cierto tiempo
function throttle(func, limit) {
  let inThrottle;
  return function(...args) {
    if (!inThrottle) {
      func(...args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

// Aplicación a eventos de scroll o resize
window.addEventListener('scroll', debounce(() => {
  // Código que actualiza algo basado en scroll
}, 200));

window.addEventListener('resize', throttle(() => {
  // Código que se ejecuta durante el resize
}, 300));

Delegación de eventos:

// En lugar de múltiples listeners
document.querySelectorAll('.item').forEach(item => {
  item.addEventListener('click', handleClick);
});

// Un solo listener con delegación
document.querySelector('.container').addEventListener('click', (e) => {
  if (e.target.matches('.item')) {
    handleClick.call(e.target, e);
  }
});

Frameworks modernos: React, Vue y patrón PRPL

Optimizaciones en React

Memoización de componentes

import React, { memo, useMemo, useCallback } from 'react';

// Memoización de componentes
const ComponenteCostoso = memo(function ComponenteCostoso(props) {
  return <div>{/* Contenido complejo */}</div>;
});

function App() {
  // Memoización de valores costosos de calcular
  const datosProcesados = useMemo(() => {
    return datos.map(procesarItem);
  }, [datos]);
  
  // Memoización de funciones
  const handleClick = useCallback(() => {
    // Lógica del manejador
  }, [dependencias]);
  
  return (
    <div>
      <ComponenteCostoso datos={datosProcesados} onClick={handleClick} />
    </div>
  );
}

Virtualización de listas

import { FixedSizeList } from 'react-window';

function ListaGrande({ items }) {
  const Row = ({ index, style }) => (
    <div style={style}>
      {items[index].text}
    </div>
  );
  
  return (
    <FixedSizeList
      height={400}
      width="100%"
      itemCount={items.length}
      itemSize={35}
    >
      {Row}
    </FixedSizeList>
  );
}

Optimizaciones en Vue

Manejo adecuado de v-for

<template>
  <!-- Usar key para optimizar la reactividad -->
  <div v-for="item in items" :key="item.id">
    {{ item.name }}
  </div>
  
  <!-- Para listas grandes, considerar renderizado condicional -->
  <virtual-list :items="items" :height="400" :item-height="40">
    <template v-slot:item="{ item }">
      {{ item.name }}
    </template>
  </virtual-list>
</template>

Computed properties vs Methods

<script>
export default {
  data() {
    return {
      items: [...],
      search: ''
    };
  },
  // Usar computed para cálculos que requieren caché
  computed: {
    filteredItems() {
      return this.items.filter(item => 
        item.name.toLowerCase().includes(this.search.toLowerCase())
      );
    }
  },
  // Usar methods para operaciones que siempre necesitan ejecutarse
  methods: {
    handleClick() {
      // Lógica que siempre debe ejecutarse
    }
  }
};
</script>

Patrón PRPL

El patrón PRPL es una estrategia para estructurar y servir aplicaciones web:

  • Push (o Preload) - Envía/precarga los recursos críticos
  • Render - Renderiza la ruta inicial lo más rápido posible
  • Pre-cache - Precarga el resto de rutas
  • Lazy-load - Carga diferida de otras rutas y recursos no críticos

Implementación básica:

<!-- Preload de recursos críticos -->
<link rel="preload" href="/css/critical.css" as="style">
<link rel="preload" href="/js/app-core.js" as="script">

<!-- Precache con Service Worker -->
<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('/sw.js');
    });
  }
</script>

<!-- Prefetch de rutas probables -->
<link rel="prefetch" href="/js/about-page.js">
// sw.js (Service Worker)
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open('app-shell-v1').then((cache) => {
      return cache.addAll([
        '/',
        '/css/critical.css',
        '/js/app-core.js',
        '/offline.html'
      ]);
    })
  );
});

Medición y casos prácticos

Herramientas de monitoreo

Sin medición, la optimización es intuición. Estas son las herramientas por fase:

Diagnóstico inicial

  • Lighthouse (Chrome DevTools): evaluación integral con TBT y TTI
  • Chrome DevTools Performance panel: análisis detallado de ejecución frame a frame
  • WebPageTest: pruebas con dispositivos y conexiones reales en diferentes condiciones

Monitorización continua en producción

  • Google Search Console (Core Web Vitals): datos de campo reales de CrUX
  • Firebase Performance Monitoring: para aplicaciones web y móviles
  • New Relic, Datadog: soluciones comerciales para producción a escala

Medir el impacto de JavaScript en Core Web Vitals

// Calcular y reportar TBT manualmente
let tbtValue = 0;
let lastLongTaskEnd = 0;

// Crear un PerformanceObserver para Long Tasks
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Calcular el tiempo de bloqueo (tiempo por encima de 50ms)
    const blockingTime = entry.duration - 50;
    if (blockingTime > 0) {
      tbtValue += blockingTime;
      lastLongTaskEnd = entry.startTime + entry.duration;
    }
  }
  
  // Enviar a analytics si supera umbral
  if (tbtValue > 300) {
    analytics.send('poor_tbt', tbtValue);
  }
});

observer.observe({entryTypes: ['longtask']});

Casos de estudio

Caso 1: E-commerce

TTI de 12s en móviles 3G con un bundle JS principal de 1.2MB.

Soluciones aplicadas: code splitting por rutas y componentes, lazy loading de carruseles y componentes bajo el pliegue, reemplazo de jQuery por Vanilla JS y preloading estratégico del JS crítico.

Resultados: TTI reducido a 4.5s (-62%), bundle inicial a 320KB (-73%) y mejora del 28% en tasa de conversión móvil.

Caso 2: Aplicación SPA

FID promedio de 250ms con animaciones entrecortadas durante scroll.

Soluciones aplicadas: migración de tareas pesadas a Web Workers, virtualización de listas largas, route-based code splitting y optimización de manejadores de eventos con throttle/debounce.

Resultados: FID reducido a 45ms (-82%), reducción del 95% en Long Tasks durante la navegación y mejora del 40% en métricas de engagement.

Estrategias según tipo de sitio

Sitios de contenido

// Esquema para sitios de contenido
document.addEventListener('DOMContentLoaded', () => {
  // Cargar inmediatamente solo lo esencial
  loadEssentialFeatures();
  
  // Diferir funcionalidades no críticas
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => {
      loadNonEssentialFeatures();
    });
  } else {
    setTimeout(loadNonEssentialFeatures, 1000);
  }
  
  // Cargar funcionalidades de interacción bajo demanda
  document.querySelector('.comments-button').addEventListener('click', () => {
    import('./comentarios.js').then(module => {
      module.initializeComments();
    });
  });
});

Aplicaciones SPA

// Framework agnostic - Estrategia de carga para SPA
import { registerRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';

// API calls con estrategia Network First (online) con fallback a caché
registerRoute(
  ({url}) => url.pathname.startsWith('/api/'),
  new NetworkFirst({
    cacheName: 'api-cache',
    networkTimeoutSeconds: 3
  })
);

// Recursos estáticos con Cache First
registerRoute(
  ({request}) => request.destination === 'script' || 
                 request.destination === 'style',
  new CacheFirst({
    cacheName: 'static-resources'
  })
);

// Precarga predictiva basada en navegación del usuario
function preloadRoutes(currentRoute) {
  const likelyNextRoutes = predictNextRoutes(currentRoute);
  likelyNextRoutes.forEach(route => {
    const link = document.createElement('link');
    link.rel = 'prefetch';
    link.href = `/js/chunks/${route}.js`;
    document.head.appendChild(link);
  });
}

Tendencias que cambian las reglas de optimización

Algunas evoluciones recientes afectan directamente a cómo se optimiza JavaScript:

  1. Los React Server Components ejecutan lógica en el servidor y envían HTML al cliente, eliminando el JS de esos componentes del bundle del navegador.
  2. ESBuild y SWC son compiladores ultrarrápidos que están reemplazando a Babel/Webpack en proyectos nuevos, con bundles más eficientes por defecto.
  3. El Edge Computing permite ejecutar scripts en nodos edge, lo que reduce la latencia de red y descarga trabajo del cliente.
  4. Vite, Parcel y Next.js incorporan ya optimizaciones automáticas (tree shaking, code splitting, preloading) que antes requerían configuración manual.

Una regla práctica para proyectos nuevos: mide el bundle con source-map-explorer o webpack-bundle-analyzer antes de optimizar. El 80% del peso suele estar en el 20% de las dependencias. Identificar cuál es moment.js en tu proyecto (y reemplazarlo por day.js) suele tener más impacto que ajustar la configuración de Webpack.

Fuentes y referencias

  1. MDN Web Docs: Web Workers API (developer.mozilla.org)

Comparte este artículo

Si te ha resultado útil este contenido, compártelo con tus colegas.

Twitter LinkedIn

Preguntas Frecuentes

¿Cuál es la diferencia entre async y defer para cargar scripts JavaScript?

async: el script se descarga en paralelo al HTML y se ejecuta inmediatamente al terminar la descarga, interrumpiendo el parsing. defer: el script se descarga en paralelo pero se ejecuta solo después de que el HTML ha sido completamente parseado, respetando el orden de los scripts. Para scripts que no dependen del DOM ni de otros scripts: async. Para scripts que necesitan el DOM completo: defer. Los scripts críticos de analytics o publicidad usan async; el JavaScript de la aplicación principal usa defer.

¿Qué es el bundle y cómo reducir su tamaño?

El bundle es el archivo JavaScript resultante de combinar todos los módulos de una aplicación. Para reducirlo: 1) Minificación con Terser (elimina espacios, renombra variables). 2) Tree shaking para eliminar código muerto. 3) Code splitting para dividir en chunks. 4) Análisis del bundle con webpack-bundle-analyzer para identificar dependencias pesadas. 5) Reemplazar librerías grandes por alternativas más ligeras (moment.js → day.js ahorra ~67KB).

¿Cómo afecta JavaScript al INP (Interaction to Next Paint)?

El INP mide la latencia de respuesta a interacciones del usuario (clics, teclado, touch). El JavaScript que se ejecuta en el hilo principal bloquea estas respuestas. Causas principales de INP alto: Long Tasks (>50ms), event handlers pesados sin debounce/throttle, re-renders innecesarios en React/Vue. Soluciones: yield to main thread con scheduler.yield(), Web Workers para cálculos pesados, y virtualización de listas largas.

Mantente actualizado

Recibe en tu email los últimos artículos, consejos y estrategias sobre SEO, rendimiento web y marketing digital.

Enviamos un boletín cada semana, y puedes darte de baja en cualquier momento.

EG

Elu Gonzalez

Experto SEO & Optimización Web