De 12 segundos a 30ms de TTFB

Cómo diagnostiqué y resolví el rendimiento crítico de un portal de noticias con WordPress de alto tráfico, logrando una mejora del 99,7 % en el tiempo de respuesta del servidor.

11,9s
TTFB antes de la intervención
30ms
TTFB después de la optimización
99,7%
Reducción del tiempo de respuesta
0
Errores 500 tras la intervención

El problema: un portal de noticias al límite

El rendimiento no es un lujo, es una necesidad. Me contactó el responsable de un portal de noticias construido sobre WordPress que sufría una lentitud crítica. El tiempo de respuesta inicial (TTFB) rondaba los 11,9 segundos en horas de tráfico normal, con picos que dejaban la web inaccesible.

Los síntomas eran claros: pantallas en blanco intermitentes, errores 500 aleatorios en operaciones de WooCommerce y una portada que tardaba casi medio minuto en servirse en momentos de tráfico alto. Google ya había comenzado a penalizar el sitio en sus métricas de Core Web Vitals.

El sitio contaba con varios años de historia, múltiples plugins acumulados y un tema personalizado con lógica de consultas propia. La instalación de WordPress había crecido de forma orgánica sin que ningún desarrollador hubiera revisado el rendimiento en profundidad desde su lanzamiento. Esta situación —muy habitual en portales con redacciones activas— genera una deuda técnica invisible que no aparece hasta que el tráfico crece o se actualiza algún plugin clave. El cliente había probado ya tres plugins de caché distintos sin éxito, lo que es una señal inequívoca de que el problema estaba en la generación del HTML, no en la entrega.

La trampa más común con WordPress lento

La mayoría de los tutoriales dicen "instala WP Rocket y listo". Pero cuando el problema está en la generación del HTML —es decir, en el código PHP que corre en el servidor antes de que exista algo que cachear—, ningún plugin de caché te salvará. Primero hay que diagnosticar.

1. El diagnóstico: ingeniería inversa del retraso

Para no trabajar a ciegas, lo primero fue implementar un profiler personalizado como mu-plugin. A diferencia de herramientas genéricas, este profiler se engancha directamente en los hooks de WordPress y registra el tiempo exacto consumido en cada etapa del ciclo de carga.

mu-plugin: profiler de WordPress personalizado
// /wp-content/mu-plugins/profiler.php
add_action('muplugins_loaded', function() {
    $GLOBALS['_prof'] = ['start' => microtime(true)];
});

add_action('setup_theme', function() {
    $GLOBALS['_prof']['setup_theme'] = microtime(true);
});

add_action('wp', function() {
    $elapsed = microtime(true) - $GLOBALS['_prof']['setup_theme'];
    // Resultado: 6,3 segundos solo en setup_theme → loop
    error_log('[PROF] setup_theme→wp: ' . round($elapsed, 4) . 's');
});

Descubrimiento crítico: el hook setup_theme y la primera ejecución del loop de noticias estaban bloqueando el hilo de ejecución durante más de 6,3 segundos. La causa: múltiples llamadas a Pods::find() sin límite de resultados y con cláusulas WHERE que impedían el uso de índices en MySQL. El problema no era el servidor, era la arquitectura de las consultas.

La diferencia entre Pods::find() mal configurado y una consulta WP_Query optimizada es abismal. Mientras que Pods genera internamente varias subconsultas para resolver relaciones entre tipos de contenido, WP_Query trabaja directamente sobre las tablas nativas de WordPress (wp_posts y wp_postmeta), que tienen índices correctamente definidos. Al reescribir las ocho consultas de la portada usando WP_Query con 'no_found_rows' => true y 'update_post_meta_cache' => false, eliminamos la sobrecarga innecesaria de calcular paginación y cargar metadatos que no se usaban. El tiempo de estas consultas pasó de más de 5 segundos a menos de 80ms en total.

2. Estabilización de la infraestructura PHP

Durante el diagnóstico detectamos un segundo problema: los límites de memoria definidos en wp-config.php estaban siendo ignorados por la configuración del servidor. El valor de WP_MEMORY_LIMIT y WP_MAX_MEMORY_LIMIT era correcto en WordPress, pero phpinfo() mostraba solo 128M disponibles.

Solución: forzar límites reales mediante .user.ini
# /public_html/.user.ini
memory_limit = 512M
max_execution_time = 300
upload_max_filesize = 64M
post_max_size = 64M

Este tipo de discrepancia entre lo que WordPress cree que tiene disponible y lo que PHP realmente asigna es más frecuente de lo que parece en hostings compartidos. El archivo .user.ini es el mecanismo estándar para sobrescribir directivas de PHP a nivel de directorio sin necesidad de acceso root al servidor. Su ventaja sobre modificar directamente php.ini es que el hosting no lo sobrescribe en actualizaciones del servidor. Es importante recordar que los cambios en .user.ini no son inmediatos: PHP-FPM los recarga cada cierto tiempo (configurable con user_ini.cache_ttl), por lo que puede ser necesario reiniciar el proceso o esperar unos minutos antes de verificar los cambios con phpinfo().

3. Resolución de conflictos en la base de datos: Action Scheduler colapsado

El sistema de tareas programadas (Action Scheduler), usado internamente por WooCommerce, estaba en un estado crítico. Más de 47.000 tareas fallidas o "claimed" se habían acumulado en las tablas wp_actionscheduler_actions y wp_actionscheduler_claims, provocando bloqueos de tabla que afectaban a todas las consultas del sitio.

El síntoma que nadie reconocía: "Commands out of sync"

El error mysqli_sql_exception: Commands out of sync; you can't run this command now en MySQL no aparece en los tutoriales de WordPress, pero es el síntoma clásico de un Action Scheduler colapsado. La conexión MySQL queda en un estado inconsistente porque una transacción anterior no se completó correctamente.

Limpieza quirúrgica de Action Scheduler via SQL
-- 1. Liberar todas las claims bloqueadas
DELETE FROM wp_actionscheduler_claims
WHERE date_created_gmt < DATE_SUB(NOW(), INTERVAL 1 HOUR);

-- 2. Marcar tareas fallidas antiguas como canceladas
UPDATE wp_actionscheduler_actions
SET status = 'canceled'
WHERE status = 'failed'
  AND scheduled_date_gmt < DATE_SUB(NOW(), INTERVAL 7 DAY);

-- 3. Optimizar las tablas afectadas
OPTIMIZE TABLE wp_actionscheduler_actions,
              wp_actionscheduler_claims;

Una vez ejecutadas las sentencias SQL de limpieza, el comportamiento del sitio mejoró notablemente incluso antes de aplicar la caché de fragmentos. Los bloqueos de tabla desaparecieron porque MySQL ya no tenía que gestionar miles de filas en estado inconsistente cada vez que WooCommerce intentaba procesar una tarea nueva. Para evitar que el problema se repita, configuramos WooCommerce para que el Action Scheduler solo intente procesar un máximo de 5 tareas concurrentes y habilitamos el Cleanup automático de tareas completadas con más de 30 días de antigüedad, una opción disponible desde el panel de WooCommerce > Status > Action Scheduler.

4. Estrategia de caché avanzada: Transients API + output buffering

Con la infraestructura estabilizada, abordamos el problema de raíz: la arquitectura de la portada. El template realizaba 8 consultas independientes de Pods para construir los módulos de noticias (últimas, destacadas, por categoría, sin repetición), cada una con lógica de exclusión de IDs ya mostrados.

Ningún plugin de caché de página puede resolver esto eficientemente porque el contenido cambia con cada publicación. La solución correcta es una caché de fragmentos a nivel de código.

Implementación: caché de fragmentos con Transients API y ob_start()
/**
 * Caché de fragmentos para la portada de noticias.
 * Se invalida automáticamente al publicar o actualizar posts.
 */
function lj_render_portada_cached() {
    $cache_key = 'portada_noticias_v1';
    $cached    = get_transient( $cache_key );

    if ( false !== $cached ) {
        echo $cached; // Servir desde caché: ~0ms
        return;
    }

    // Capturar el HTML de la portada con output buffering
    ob_start();
    lj_render_portada_real(); // Las 8 consultas costosas
    $html = ob_get_clean();

    // Guardar en transient: expira en 5 minutos
    set_transient( $cache_key, $html, 5 * MINUTE_IN_SECONDS );

    echo $html;
}

// Invalidar caché al publicar o actualizar
add_action( 'save_post', function() {
    delete_transient( 'portada_noticias_v1' );
});
Por qué esto es mejor que WP Rocket para portadas dinámicas

WP Rocket y similares cachean la página completa, lo que genera problemas con contenido dinámico (usuario logueado, carrito de WooCommerce, posts en borradores). La caché de fragmentos solo cachea el bloque pesado y deja el resto dinámico, dando lo mejor de ambos mundos.

El resultado: velocidad extrema y web estable

Tras la intervención completa, los resultados superaron las expectativas del cliente:

Más allá de los números, el impacto real se tradujo en una experiencia de usuario completamente diferente. Los lectores del portal podían navegar entre secciones sin esperas, las páginas de artículos cargaban de forma instantánea y el equipo de redacción dejó de recibir avisos de caída del sitio durante los momentos de mayor tráfico —habitualmente tras publicar noticias de última hora. La mejora en Core Web Vitals también tuvo efecto en el posicionamiento orgánico: en los dos meses siguientes a la intervención, el sitio recuperó posiciones en Google para varias de sus palabras clave principales.

Este caso ilustra una lección que se repite con frecuencia: la mayoría de los problemas graves de rendimiento en WordPress no tienen solución con plugins, sino con ingeniería. Diagnosticar antes de actuar, entender el ciclo de ejecución de PHP y WordPress, y aplicar las soluciones en la capa correcta —base de datos, infraestructura PHP, arquitectura de caché— es lo que marca la diferencia entre un parche temporal y una web que funciona bien durante años.

Resumen del proceso de intervención

  1. Profiler personalizado como mu-plugin para identificar exactamente qué hook consumía los 6+ segundos.
  2. Corrección de límites PHP mediante .user.ini para eliminar errores de memoria en WooCommerce.
  3. Limpieza quirúrgica de Action Scheduler: liberación de claims bloqueados y cancelación de 47.000 tareas fallidas.
  4. Refactoring de consultas Pods: reescritas con WP_Query nativo para aprovechar los índices de MySQL.
  5. Caché de fragmentos con Transients API + ob_start() para la portada dinámica.
  6. WP Super Cache como capa de caché estática para el resto del sitio.

Preguntas frecuentes sobre optimización de WordPress

WP Rocket y similares solo pueden cachear lo que el servidor ya ha generado. Si el problema está en la generación del HTML —consultas SQL lentas, plugins mal codificados, Action Scheduler colapsado— ningún plugin de caché lo puede resolver. Primero hay que diagnosticar con un profiler qué hook o consulta está bloqueando la ejecución de PHP.

El TTFB (Time To First Byte) es el tiempo que tarda el servidor en enviar el primer byte de respuesta. Google considera menos de 800ms como "Bueno" en Core Web Vitals. Para WordPress con caché activa, un TTFB por debajo de 200ms es alcanzable. Por encima de 2 segundos ya es problemático para el SEO y la experiencia de usuario.

Ve a tu base de datos con phpMyAdmin o mediante WP-CLI y ejecuta: SELECT status, COUNT(*) FROM wp_actionscheduler_actions GROUP BY status;. Si ves miles de tareas en estado "failed" o "in-progress" (claimed) con fechas antiguas, el Action Scheduler está colapsado y está generando bloqueos de tabla en MySQL que ralentizan todo el sitio.

Sí, y es la forma recomendada por WordPress para cachear datos temporales. Por defecto usa la base de datos como backend, pero si hay un plugin de object cache como Redis Object Cache o W3 Total Cache con Memcached, los transients se almacenan automáticamente en memoria, haciendo las lecturas prácticamente instantáneas (<1ms).

¿Tu WordPress no rinde como debería?

Analizo tu sitio sin compromiso. Si el problema existe, te explico exactamente qué lo está causando y cómo lo solucionamos. No cobro por el diagnóstico inicial.

Solicitar diagnóstico WordPress →

← Volver al Blog

💬 8 comentarios

MR

Llevaba meses con el mismo problema y no entendía por qué WP Rocket no lo solucionaba. El query del Action Scheduler que propones me abrió los ojos: tenía 83.000 tareas en estado "failed". Después de limpiarlas, el TTFB bajó de 9 s a 400 ms en frío. No al nivel tuyo, pero ya es respirable. ¿Algún consejo para bajar ese último tramo?

LJ

Marcos, 400 ms en frío ya es un salto enorme, bien hecho. Para bajar más, lo siguiente que miraría es si alguna consulta sigue disparándose en setup_theme o wp_head. Añade el profiler que menciono en el artículo como mu-plugin y vuelca el log: casi siempre hay un plugin secundario con una consulta sin caché que explica esos 400 ms restantes. Si te atascas, escríbeme por el formulario de contacto.

SV

Artículo técnico de los buenos, sin relleno. La parte de la caché de fragmentos con ob_start() + Transients es exactamente lo que necesitaba para la portada de un cliente con Elementor que hace 3 queries pesadísimas. ¿Funciona bien aunque el tema sea de Elementor o hay algún conflicto con su sistema de caché interno?

LJ

Sara, con Elementor funciona bien siempre que apliques el fragmento en un bloque de código PHP (require un plugin como "Code Snippets") o directamente en el functions.php del child theme, enganchado a un shortcode o a una función que Elementor llame. Lo que hay que evitar es cachearlo si el usuario está logueado o tiene productos en el carrito (WooCommerce). Con un if (is_user_logged_in()) return; antes del ob_start() vas segura.

JP

Leer esto fue el momento "ahá" que necesitaba. Tenía un hook woocommerce_before_shop_loop con una consulta Pods sin caché que se ejecutaba en cada página de producto. Tardaba 4 s de TTFB. Reescribí la consulta con WP_Query tal como describes y metí un transient de 1 hora. Ahora el TTFB está en 180 ms. El cliente no se lo creía.

AM

¿Tendrías el código completo del profiler como mu-plugin que usas para la diagnosis? El que muestro en el artículo es suficiente para detectar el hook, pero me gustaría ver si también loggas el tiempo de cada consulta SQL de ese hook.

LJ

Ana, buena pregunta. El profiler que uso internamente también captura las queries SQL combinando el hook timer con $wpdb->num_queries antes y después. Lo tengo documentado como un segundo mu-plugin complementario. Tengo pendiente publicarlo como artículo independiente con el código completo y ejemplos de output. En breve estará por aquí.

RC

Llegué aquí desde una búsqueda en Google de "Action Scheduler 50000 failed tareas". Ejecuté el query de diagnóstico y tenía exactamente 51.400 acciones fallidas en mi tienda WooCommerce. Después de limpiarlas con DELETE FROM wp_actionscheduler_actions WHERE status = 'failed'; y las "in-progress" con fecha antigua, la administración de WordPress que tardaba 30 segundos en cargar ahora va fluida. Un artículo que debería ser lectura obligatoria para cualquier administrador de WooCommerce.

IE

Mi caso era diferente: no era WooCommerce sino un portal con Pods muy grande. El TTFB no bajaba de 7 s a pesar de tener W3 Total Cache activo. Contacté con Luis por el formulario y en menos de 48 horas me había localizado el problema: tres campos relacionales de Pods sin lazy loading que se resolvían en cada carga de página aunque no se mostraran en esa vista. Migración a WP_Query con meta queries y el sitio voló. Muy recomendable si quieres a alguien que realmente entiende lo que hay debajo del WordPress.

Se ha cerrado el hilo de comentarios para este artículo.