De 92 a 99 en móvil: Core Web Vitals en un sitio Elementor
Por Rolando Escobar, desarrollador WordPress para agencias · · 6 min de lectura
La primera medición de Lighthouse en móvil después de reconstruir rolandowp.com le dio a la home 92 de rendimiento, con un Largest Contentful Paint de 2.8 segundos. El LCP era el problema evidente, así que el trabajo era concreto: encontrar qué estaba esperando el elemento LCP y quitarlo del camino.
Con los cambios de abajo, la misma página saca 99 en móvil: LCP de 1.9 s, Total Blocking Time de 0 ms y Cumulative Layout Shift de 0, con 100 en Accesibilidad, Buenas prácticas y SEO. El stack es WordPress 7.1 con Elementor y Elementor Pro 4.3, construido con elementos atómicos V4 sobre un tema hijo de Hello Elementor. Ya he trabajado Core Web Vitals en sitios de clientes de EE. UU.; este sitio es mío, así que puedo mostrar los números.
Mide siempre de la misma forma
Las puntuaciones de laboratorio cambian de una corrida a otra, y una sola medición te puede poner a perseguir ruido. Corre Lighthouse varias veces con la misma configuración y compara los resultados, no una puntuación con suerte. Por defecto, Lighthouse emula un teléfono de gama media con una conexión limitada, igual que la puntuación móvil de PageSpeed Insights:
#!/usr/bin/env bash
# Tres corridas de Lighthouse en móvil y luego los números que importan de cada reporte.
set -euo pipefail
URL="${1:?Uso: ./lh.sh https://example.com/}"
for i in 1 2 3; do
npx lighthouse "$URL" \
--only-categories=performance \
--output=json --output-path="./lh-$i.json" \
--chrome-flags="--headless=new" --quiet
done
for f in lh-*.json; do
node -e '
const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
const a = r.audits;
console.log(process.argv[1],
"score", Math.round(r.categories.performance.score * 100),
"LCP", a["largest-contentful-paint"].displayValue,
"CLS", a["cumulative-layout-shift"].displayValue,
"TTFB", Math.round(a["server-response-time"].numericValue) + " ms");
' "$f"
done
Averigua qué está esperando el elemento LCP
Empieza por el desglose del LCP en el reporte. En mi home, el elemento LCP es el titular del hero, que es texto y no una imagen. Un LCP de texto no tiene imagen que descargar: lo que espera es la fuente. Las fuentes web se descubren tarde, porque el navegador solo conoce la URL del woff2 después de descargar y leer la hoja de estilos que la declara. Mientras el archivo no llega, el titular espera o se pinta con una fuente de respaldo y se vuelve a pintar.
El titular quedaba al final de una cadena: el HTML, luego una hoja de estilos y después el archivo de fuente que esa hoja declara. En una conexión móvil limitada, cada paso cuesta un viaje de ida y vuelta. La solución tiene dos partes: menos fuentes y empezar antes la única que importa.
De cinco familias de fuentes a dos
El sitio cargaba cinco familias de fuentes desde Google, entre ellas fuentes DM que venían de un fragmento viejo. El diseño nuevo solo necesita dos: IBM Plex Sans para todo e IBM Plex Mono para los detalles con estilo de código. Quité el fragmento, dejé solo esas dos familias y activé la opción de Elementor para cargar las Google Fonts de forma local.
Servir las fuentes desde tu propio dominio elimina dos orígenes extra (fonts.googleapis.com y fonts.gstatic.com), y en móvil cada uno cuesta una consulta DNS y una negociación TLS. Además deja de enviar la IP de cada visitante a un tercero, algo que tu política de privacidad agradece.
Para encontrar fuentes perdidas, filtra el panel Network de DevTools por «Font» y busca googleapis en el código fuente de la página. Las fuentes viejas suelen esconderse en el Custom Code de Elementor, el CSS adicional del Personalizador, un plugin de snippets o un widget con la familia puesta a mano en lugar de usar una global.
Precarga el único archivo que necesita el titular
IBM Plex Sans se sirve como fuente variable, así que un solo woff2 latino cubre todos los pesos en estilo normal. Ese es el archivo que hay que precargar. El problema es que Elementor genera los archivos de fuentes locales por su cuenta, y sus nombres no son algo que convenga escribir a mano. El tema hijo lee el CSS de fuentes locales de Elementor, encuentra el woff2 latino normal, guarda la URL en un transient e imprime la precarga al principio del <head>:
<?php
/**
* Precarga el woff2 latino de IBM Plex Sans que Elementor sirve localmente,
* para que el titular del hero (el elemento LCP) no espere la cadena de CSS.
*/
function rw_preload_primary_font() {
$uploads = wp_get_upload_dir();
$css = $uploads['basedir'] . '/elementor/google-fonts/css/ibmplexsans.css';
if ( ! is_readable( $css ) ) {
return;
}
$mtime = (int) filemtime( $css );
$cached = get_transient( 'rw_plex_latin_woff2' );
if ( ! is_array( $cached ) || $cached['mtime'] !== $mtime ) {
$url = '';
$raw = (string) file_get_contents( $css ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
// El primer @font-face de estilo normal después de un comentario /* latin */.
if ( preg_match( '#/\*\s*latin\s*\*/\s*@font-face\s*\{[^}]*font-style:\s*normal;[^}]*url\(\s*[\'"]?([^\'")]+\.woff2)#', $raw, $m ) ) {
$url = $m[1];
}
$cached = [ 'mtime' => $mtime, 'url' => $url ];
set_transient( 'rw_plex_latin_woff2', $cached, DAY_IN_SECONDS );
}
if ( $cached['url'] ) {
printf(
'<link rel="preload" href="%s" as="font" type="font/woff2" crossorigin>' . "\n",
esc_url( $cached['url'] )
);
}
}
add_action( 'wp_head', 'rw_preload_primary_font', 1 );
Algunos detalles de los que depende que esto funcione:
crossorigines obligatorio, incluso para una fuente en tu propio dominio. Las fuentes siempre se piden en modo CORS, y una precarga sin el atributo no coincide con la petición real, así que el navegador descarga el archivo dos veces.- La prioridad 1 en
wp_headpone la etiqueta cerca del inicio del<head>, antes de las hojas de estilo, para que la descarga empiece junto con el HTML. - La expresión regular ignora
latin-exty las cursivas.[^}]*mantiene cada coincidencia dentro de un solo bloque@font-face. - Precarga un archivo, no la familia completa. Cada precarga compite por ancho de banda con el CSS que la página necesita para pintarse.
La versión de mi sitio simplemente guarda la URL por un día. Guardar junto a ella la fecha de modificación del CSS, como arriba, es una mejora que vale la pena: si Elementor regenera sus fuentes, la siguiente visita lo nota y la precarga nunca apunta a un archivo que ya no existe. La referencia de rel="preload" en MDN y la guía Optimize LCP de web.dev explican las reglas de fondo.
Imágenes que reservan su espacio
Las portadas de los proyectos son WebP de 1280×800, con los atributos width y height explícitos. Con los dos atributos, el navegador conoce la proporción antes de que llegue el archivo y reserva la caja, y por eso el Cumulative Layout Shift se queda en 0 mientras carga la grilla. WordPress agrega los atributos cuando la imagen viene de la biblioteca de medios; donde se pierden es en imágenes pegadas como HTML o puestas como fondo con CSS.
Este fragmento para la consola revisa una página en segundos:
// Pégalo en la consola de DevTools: lista las imágenes que pueden mover el diseño o frenar el LCP.
const rows = [];
for (const img of document.images) {
const box = img.getBoundingClientRect();
if (box.width === 0) continue; // Oculta, como el logo del otro modo de color.
const issues = [];
if (!img.hasAttribute('width') || !img.hasAttribute('height')) {
issues.push('sin width/height');
}
if (box.top < window.innerHeight && img.loading === 'lazy') {
issues.push('carga diferida en la primera pantalla');
}
if (img.naturalWidth > box.width * window.devicePixelRatio * 1.5) {
issues.push(`${img.naturalWidth}px servidos para ${Math.round(box.width)}px`);
}
if (issues.length) rows.push({ src: img.currentSrc, issues: issues.join('; ') });
}
console.table(rows);
Una imagen visible en la primera pantalla nunca debe tener carga diferida. Si tu elemento LCP es una imagen y no texto, además debe ser la que lleve fetchpriority="high".
Lo que todavía queda por hacer
Un 99 no es una página perfecta, y el reporte todavía muestra tres oportunidades. Prefiero nombrarlas que esconderlas:
- El servidor tarda unos 650 ms en responder. Todavía no hay caché de página, así que WordPress arma cada página en cada visita. La caché de página es la mayor ganancia pendiente (ver web.dev sobre TTFB). Antes de activarla, prueba todo lo que imprima un nonce en el HTML, como los formularios: los nonces de WordPress vencen en menos de un día y una página en caché puede durar más que ellos.
- Unos 50 KB de JavaScript sin usar de jQuery y del propio Elementor. Con el TBT en 0 ms, cuesta bytes y no tiempo del hilo principal. Quitar cualquiera de los dos es pelearse con el constructor por una ganancia pequeña.
- CSS que bloquea el renderizado en los archivos por documento de Elementor: el kit, el header, el footer y la página tienen cada uno su hoja de estilos. Poner en línea el CSS crítico o cambiar cómo Elementor imprime su CSS puede ayudar, pero ambas opciones cambian la reutilización de la caché entre páginas por una primera vista más rápida, así que mide antes y después.
Qué vigilar
- Fragmentos viejos y Custom Code que vuelvan a traer Google Fonts después de pasar a fuentes locales. Revisa el panel Network después de cada cambio de plugin o fragmento.
- URLs de precarga escritas a mano. Cuando Elementor regenera sus fuentes locales, esa URL se vuelve un 404 que igual cuesta una petición.
- Precargar fuentes que no aparecen en la primera pantalla, o varios pesos de una fuente estática. Cada una retrasa el CSS.
- Una imagen LCP con carga diferida, muchas veces por un slider o un plugin de lazy loading que no respeta la primera imagen.
- Los datos de laboratorio no son datos de campo. Cuando el sitio tenga tráfico, confirma las mejoras en el Chrome UX Report y en el informe de Core Web Vitals de Search Console.
Este es el tipo de trabajo que hago para clientes: Desarrollo con Elementor