Una página puede ir lenta sin una sola consulta lenta. Veinte productos, y por cada uno una consulta para su categoría, su precio y su imagen: sesenta y una consultas donde bastarían dos. Cada una tarda dos milisegundos y la página tarda segundo y medio.

Cuéntelas primero

// at the end of the page
echo count($pdo->query('SHOW SESSION STATUS LIKE "Questions"')->fetchAll());

O lea la cuenta en la barra de depuración del framework, o en el registro de lentas con long_query_time a 0 durante una sola petición. El número importa más que cualquier medición aislada: por debajo de 20 es normal, por encima de 100 es el fallo.

Cómo se ve en el código

// N+1: one query, then one more per row
$products = query('SELECT * FROM products LIMIT 20');
foreach ($products as $p) {
    $p->category = query('SELECT * FROM categories WHERE id = ?', $p->category_id);
}

El remedio es un join, o una consulta para todos los identificadores

$ids = array_column($products, 'category_id');
$cats = query('SELECT * FROM categories WHERE id IN (' . placeholders($ids) . ')', $ids);
// then match them up in PHP

Dos consultas en lugar de veintiuna, y no empeora cuando la página muestra cien productos, que es justamente la propiedad que importa.

En un ORM

Para esto existe la carga anticipada. El valor perezoso por defecto es cómodo y produce exactamente esta forma, y el remedio suele ser una sola palabra.

$products = Product::with('category', 'images')->limit(20)->get();
Cachear una página así lo esconde hasta que la caché se enfría, y el primer visitante después de cada despliegue se lleva las 400 consultas enteras. Arregle la cuenta y luego cachee.
Vigile el número de consultas según crece la página. Una página que hace 3 consultas para 1 fila y 300 para 100 filas tiene esta forma, digan lo que digan los tiempos de hoy.