Los Core Web Vitals son las métricas con las que Google mide la experiencia de carga de una página: LCP (Largest Contentful Paint), INP (Interaction to Next Paint) y CLS (Cumulative Layout Shift). Aunque gran parte se decide en el frontend, el servidor marca el punto de partida: nada se pinta hasta que llega el primer byte, así que un TTFB alto arrastra directamente el LCP. En este tutorial medirás el TTFB de un sitio PHP servido con Nginx en Ubuntu 24.04 y lo reducirás con OPcache, un pool de PHP-FPM bien dimensionado, la caché FastCGI de Nginx, compresión Brotli y cabeceras de caché para los estáticos.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath.
- Un usuario no root con privilegios
sudo. - Nginx y PHP 8.3 con PHP-FPM (
php8.3-fpm, el de Ubuntu 24.04) sirviendo una aplicación entudominio.com, por ejemplo WordPress. - HTTPS configurado en el sitio.
jqpara leer respuestas JSON:sudo apt install jq.
Qué puede mejorar el servidor
| Métrica | Umbral "bueno" | Palanca en el servidor |
|---|---|---|
| TTFB | 800 ms o menos | OPcache, PHP-FPM, caché de página |
| LCP | 2,5 s o menos | TTFB bajo, compresión, caché de estáticos, imágenes ligeras |
| INP | 200 ms o menos | Casi todo en el cliente (JavaScript) |
| CLS | 0,1 o menos | Casi todo en el cliente (dimensiones de imágenes, fuentes) |
Esta guía se centra en TTFB y LCP, que es donde la configuración del servidor tiene efecto directo.
Paso 1: Medir el punto de partida
Mide desde otra máquina, no desde el propio servidor, para incluir la latencia de red. curl desglosa cada fase de la petición:
curl -o /dev/null -s -w "DNS: %{time_namelookup}s\nTCP: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n" https://tudominio.com/
DNS: 0.012s
TCP: 0.041s
TLS: 0.089s
TTFB: 0.912s
Total: 0.968s
La diferencia entre TTFB y TLS es el tiempo que el servidor tarda en generar la página (aquí, más de 800 ms). Ejecuta el comando varias veces y anota un valor típico.
Obtén también las métricas de laboratorio de Lighthouse con la API pública de PageSpeed Insights:
curl -s "https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=https://tudominio.com/&strategy=mobile" \
| jq '.lighthouseResult.audits | {ttfb: .["server-response-time"].displayValue, lcp: .["largest-contentful-paint"].displayValue, cls: .["cumulative-layout-shift"].displayValue}'
{
"ttfb": "Root document took 890 ms",
"lcp": "3.8 s",
"cls": "0.02"
}
Paso 2: Ajustar OPcache
OPcache guarda en memoria el código PHP ya compilado, de modo que no se vuelve a analizar en cada petición. Viene activado en Ubuntu, pero con valores pensados para sitios pequeños. Crea un archivo de ajustes que se cargue después del de por defecto:
sudo nano /etc/php/8.3/fpm/conf.d/99-opcache-tuning.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
memory_consumption(MB) ymax_accelerated_filesdeben ser suficientes para que quepa todo el código; WordPress con plugins supera fácilmente los 10.000 archivos.revalidate_freq=60hace que PHP compruebe si los archivos cambiaron como mucho una vez por minuto. Si despliegas de forma controlada, puedes poneropcache.validate_timestamps=0y ejecutarsudo systemctl reload php8.3-fpmtras cada despliegue.
Reinicia PHP-FPM y comprueba los valores:
sudo systemctl restart php8.3-fpm
php-fpm8.3 -i | grep -E 'opcache.memory_consumption|opcache.max_accelerated_files'
opcache.max_accelerated_files => 20000 => 20000
opcache.memory_consumption => 256 => 256
Paso 3: Dimensionar el pool de PHP-FPM
Si todas las peticiones concurrentes superan el número de procesos PHP disponibles, las demás esperan en cola y el TTFB se dispara. Averigua cuánta memoria usa de media cada proceso:
ps --no-headers -o rss -C php-fpm8.3 | awk '{sum+=$1; n++} END {printf "%.0f MB de media en %d procesos\n", sum/n/1024, n}'
62 MB de media en 6 procesos
Divide la memoria que puedes dedicar a PHP entre ese valor. Por ejemplo, en un servidor de 4 GB reservando 1,5 GB para el sistema, MySQL y Nginx: 2.500 MB / 62 MB = unos 40 procesos. Edita el pool:
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
Localiza y ajusta estas directivas:
pm = dynamic
pm.max_children = 40
pm.start_servers = 8
pm.min_spare_servers = 4
pm.max_spare_servers = 12
pm.max_requests = 500
request_slowlog_timeout = 2s
slowlog = /var/log/php8.3-fpm-slow.log
pm.max_requests recicla cada proceso tras 500 peticiones para contener fugas de memoria, y el slowlog registra la traza de cualquier petición que tarde más de 2 segundos. Comprueba la sintaxis y recarga:
sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpm
NOTICE: configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful
Si en el log de PHP-FPM aparece server reached pm.max_children setting, el pool se queda corto:
sudo grep max_children /var/log/php8.3-fpm.log
Paso 4: Activar la caché FastCGI de Nginx
La mayor reducción de TTFB llega al servir las páginas públicas desde caché, sin ejecutar PHP. Nginx guarda la respuesta de PHP-FPM en disco y la entrega directamente a los siguientes visitantes. Define la zona de caché y las reglas de exclusión en el contexto http:
sudo nano /etc/nginx/conf.d/fastcgi-cache.conf
fastcgi_cache_path /var/cache/nginx/fastcgi levels=1:2 keys_zone=PAGES:50m max_size=1g inactive=60m use_temp_path=off;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
# No cachear usuarios con sesión iniciada
map $http_cookie $skip_cache_cookie {
default 0;
"~*wordpress_logged_in|wp-postpass|comment_author|woocommerce_items_in_cart|PHPSESSID" 1;
}
# No cachear zonas privadas
map $request_uri $skip_cache_uri {
default 0;
"~*^/(wp-admin|wp-login\.php|cart|checkout|my-account)" 1;
}
Los patrones de ejemplo son los de WordPress y WooCommerce; adáptalos a las cookies de sesión y rutas privadas de tu aplicación. Ahora edita el bloque location de PHP en /etc/nginx/sites-available/tudominio.com:
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($skip_cache_cookie) { set $skip_cache 1; }
if ($skip_cache_uri) { set $skip_cache 1; }
fastcgi_cache PAGES;
fastcgi_cache_valid 200 301 10m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache_use_stale error timeout updating http_500;
fastcgi_cache_lock on;
add_header X-Cache-Status $upstream_cache_status;
}
fastcgi_cache_valid 200 301 10mguarda las páginas 10 minutos; súbelo si tu contenido cambia poco.fastcgi_cache_use_stalesirve la copia antigua si PHP falla o mientras se regenera, yfastcgi_cache_lockevita que diez visitas simultáneas generen la misma página diez veces.- Nginx no guarda en caché respuestas que incluyen
Set-Cookie, así que las páginas que inician sesión quedan fuera automáticamente.
Comprueba y recarga:
sudo nginx -t
sudo systemctl reload nginx
Pide la misma página dos veces y mira la cabecera de estado:
curl -sI https://tudominio.com/ | grep -i x-cache-status
curl -sI https://tudominio.com/ | grep -i x-cache-status
x-cache-status: MISS
x-cache-status: HIT
MISS significa que se generó con PHP y se guardó; HIT, que se sirvió desde caché; BYPASS, que se saltó por alguna regla de exclusión. Para vaciar la caché tras cambiar contenido, borra su contenido con sudo find /var/cache/nginx/fastcgi -type f -delete.
Paso 5: Comprimir con Brotli
Cuanto menos pesan el HTML, el CSS y el JavaScript, antes se descargan los recursos que bloquean el renderizado. Instala los módulos de Brotli de Ubuntu:
sudo apt install libnginx-mod-http-brotli-filter libnginx-mod-http-brotli-static
Crea la configuración de compresión:
sudo nano /etc/nginx/conf.d/compression.conf
# "gzip on;" ya está en nginx.conf: no lo repitas aquí
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
brotli on;
brotli_comp_level 5;
brotli_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
Recarga y verifica:
sudo nginx -t && sudo systemctl reload nginx
curl -s -o /dev/null -D - -H "Accept-Encoding: br, gzip" https://tudominio.com/ | grep -i content-encoding
content-encoding: br
Paso 6: Cachear los estáticos en el navegador
En las visitas repetidas, el navegador no debería volver a pedir CSS, JavaScript, fuentes ni imágenes. Añade este bloque dentro del server de tu sitio, antes del location de PHP:
location ~* \.(css|js|mjs|woff2|svg|png|jpe?g|gif|webp|avif|ico)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
Importante
immutabley un año de caché solo son seguros si el nombre del archivo cambia con cada versión (app.3f9a1c.cssostyle.css?ver=6.6, como hace WordPress). Si publicas siempre el mismo nombre, reduce la duración.
Recarga Nginx y comprueba un recurso:
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://tudominio.com/wp-includes/css/dist/block-library/style.min.css | grep -iE 'cache-control|expires'
cache-control: public, max-age=31536000, immutable
Paso 7: Volver a medir
Repite las mediciones del paso 1, primero con curl:
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s\n" https://tudominio.com/
TTFB: 0.094s
Y después con PageSpeed Insights. Los datos de laboratorio cambian en el momento; los datos reales de usuarios (el bloque loadingExperience de la misma respuesta, procedente del Chrome UX Report) tardan hasta 28 días en reflejar la mejora.
Solución de problemas
x-cache-status siempre muestra BYPASS. Alguna cookie coincide con el mapa de exclusión. Revisa las cookies que envía tu navegador; si la aplicación crea PHPSESSID para todos los visitantes, la caché nunca se usará hasta que deje de hacerlo en páginas públicas.
La cabecera x-cache-status no aparece. Otro add_header en un nivel superior no se hereda, o la petición no pasa por el location de PHP. Comprueba con sudo nginx -T | grep -n fastcgi_cache que la configuración cargada es la esperada.
El TTFB sigue alto con HIT. El problema ya no es PHP: revisa la latencia de red con los tiempos TCP y TLS del paso 1 y considera activar HTTP/3 o servir el sitio desde una región más cercana a tus usuarios.
Peticiones lentas sin caché. Consulta el slowlog configurado en el paso 3 para ver en qué función se pierde el tiempo:
sudo tail -n 40 /var/log/php8.3-fpm-slow.log
Conclusión
Has reducido el TTFB sirviendo las páginas públicas desde la caché FastCGI, has acelerado las que requieren PHP con OPcache y un pool bien dimensionado, y has aligerado y cacheado los recursos que determinan el LCP. Como siguientes pasos, optimiza las imágenes con versiones WebP y AVIF, activa HTTP/3 en Nginx y añade la medición de PageSpeed Insights a tu proceso de despliegue para detectar regresiones.
