Product reviews look innocent. A star rating in the listing, a list of comments on the product page, an admin grid to moderate them. What could possibly be slow about that?
Plenty. Reviews are one of those Magento subsystems where the visible surface is small but the hidden machinery is disproportionate: a full aggregation cron that recomputes every rating from scratch every 15 minutes, per-product summary queries that turn a 30-product listing into 30 extra SQL round-trips, and an admin grid that joins product names across the whole catalog. On stores with a few hundred thousand reviews — which takes surprisingly little time to accumulate — reviews quietly become one of the top five slow things in your stack.
This guide walks through the three real bottlenecks (the cron, the N+1, the grid), what to measure, and the fixes that actually work.
How reviews are stored
Before optimizing anything, know the tables. Magento 2 spreads a single "review" across four tables:
-
review— the review itself:entity_pk_value(product id),entity_id(review entity type),status_id(1 = approved, 2 = pending, 3 = not approved),created_at. -
review_detail— the localized content: title, detail text, nickname, customer id, per store view. This is the table that grows fastest on multi-store setups, because a review gets one row per store view. -
rating_option_vote— the raw star votes, one row per rating per review.percent,value,review_id,entity_pk_value. -
review_entity_summary— the pre-aggregated numbers per product per store:reviews_count,ratings_summary. This is what the storefront actually reads, so it's the table that must stay fast.
The design is fine — the problem is how the aggregated table gets maintained.
The 15-minute aggregation cron (the real killer)
Magento ships a cron job called aggregate_reviews (Magento\Review\Cron\AggregateReviews), scheduled every 15 minutes in core. For every store view, it:
- Deletes all rows from
review_entity_summaryandrating_option_vote_aggregatedfor that store, - Recomputes them from scratch by joining
rating_option_voteagainstreview(filtered on approved status), - Re-inserts the results.
That's a full recompute of every rating in the store — not an incremental update. On a store with 200k reviews and 500k votes, this single job is a multi-hundred-million-row scan executed four times an hour, and it shows up in your slow-query log as a monster INSERT ... SELECT ... GROUP BY that runs for minutes. It also fires during business hours, right when you'd rather not have a heavyweight aggregating query competing with checkout traffic.
How to check if this is you:
-- longest aggregate runs in the last 7 days
SELECT job_code, status, scheduled_at, executed_at, finished_at,
TIMESTAMPDIFF(SECOND, executed_at, finished_at) AS seconds
FROM cron_schedule
WHERE job_code = 'aggregate_reviews'
AND executed_at > NOW() - INTERVAL 7 DAY
ORDER BY seconds DESC
LIMIT 10;
If you see runs of minutes, or overlaps (the next run starting before the previous finished — look for status = 'running' while a new row is scheduled), the job is a problem.
Fixes, in order of effort:
1. Move it off-peak. The schedule lives in app/code/Magento/Review/etc/crontab.xml. Don't patch core — override it in a small custom module or via a deployment script that rewrites the config. Moving the job from */15 * * * * to e.g. 5 */1 * * * (hourly) or even daily at 03:00 changes nothing for the storefront: review_entity_summary is only read after a cache refresh anyway, and a couple of hours of staleness on star ratings is invisible to customers. What you lose is nothing; what you gain is 90+ fewer full recomputes per day.
2. Batch it. If even one daily run is too heavy, write your own aggregator that processes products in chunks (e.g. 5,000 products per transaction) instead of one giant INSERT ... SELECT. Same math, no long locks, no temp-table spill to disk.
3. Reduce contention. Make sure the job runs in a cron pool with a dedicated PHP-FPM/CLI environment and enough tmp_table_size/max_heap_table_size — the aggregation is a textbook "create temporary table on disk" query when MySQL's in-memory temp table limit is too low.
The N+1 on listings and product pages
The storefront reads review_entity_summary, which is the good news — it's a small, indexed table. The bad news: many themes render star ratings in listings (category pages, related products, recently viewed, wishlist widgets), and the core Magento\Review\Block\Product\ReviewRenderer loads the summary per product. Thirty products → thirty SELECT ... FROM review_entity_summary WHERE entity_pk_value = ? queries per page view. Add a "recently viewed" strip with ratings and you're at 45–60 queries before anyone even looks at the product.
Worse is the product page with a review list: the toolbar, pagination count and each review's vote data all trip additional lookups on review, review_detail and rating_option_vote. On a product with 500 reviews you're now loading a lot of rows for a tab most visitors never open. (Core paginates at 10 per page — good — but the count query and the vote joins still run.)
The fix is preloading, not caching:
// In your listing block: collect product ids first, then load ALL summaries in ONE query
$productIds = array_map(fn($p) => $p->getId(), $products);
/** @var \Magento\Review\Model\ResourceModel\Review\Summary\Collection $summaries */
$summaries = $this->summaryCollectionFactory->create();
$summaries->addEntityFilter($productIds) // batch: WHERE entity_pk_value IN (...)
->addStoreFilter($storeId)
->load();
$summaryMap = [];
foreach ($summaries as $summary) {
$summaryMap[$summary->getEntityPkValue()] = $summary;
}
// Then feed each renderer instead of letting it query
foreach ($products as $product) {
/** @var \Magento\Review\Block\Product\ReviewRenderer $renderer */
$summary = $summaryMap[$product->getId()] ?? null;
$renderer->setProduct($product);
$renderer->setRatingSummary($summary ? $summary->getRatingSummary() : 0);
$renderer->setReviewsCount($summary ? $summary->getReviewsCount() : 0);
echo $renderer->toHtml();
}
One query for the whole page instead of one per product. This is the same pattern that fixes related products, wishlist and recently viewed — the block is the bottleneck, not the table.
For the review list on the product page: keep core's pagination, but lazy-load the list itself (an AJAX "load reviews" button) if your product pages carry hundreds of reviews. The star summary in the header is the part customers actually see; the full list can arrive on demand.
The slow admin grid
The admin "Reviews" grid (Magento\Review\Model\ResourceModel\Review\Product\Collection) joins the review tables against catalog_product_entity plus a store-scoped product name lookup. With hundreds of thousands of reviews, three things degrade:
-
Unfiltered grid loads — the count and first-page queries scan the whole
reviewtable; theentity_pk_valuecolumn is not meaningfully selective when the grid has no filter. - The product-name join — resolving names per store across a big catalog makes every grid refresh expensive.
-
Missing composite indexes — single-column indexes don't help
WHERE status_id = ? AND entity_pk_value IN (...)style lookups.
Indexes worth adding (after testing on staging):
ALTER TABLE review
ADD INDEX IDX_REVIEW_STATUS_ENTITY (status_id, entity_pk_value),
ADD INDEX IDX_REVIEW_CREATED_AT (created_at);
ALTER TABLE review_detail
ADD INDEX IDX_REVIEW_DETAIL_STORE (store_id);
ALTER TABLE rating_option_vote
ADD INDEX IDX_RATING_VOTE_REVIEW (review_id);
Also: encourage your moderators to filter (pending status, date range, product) instead of paging through the full grid, and run a periodic cleanup of old not approved reviews to keep the table from growing with spam that nobody will ever moderate.
Cache behavior: review bursts are cache churn
A submitted review invalidates the product page's full-page cache tag (catalog_product_{id}) — Magento does this deliberately so the new rating shows up. That's correct behavior, but it has a scaling consequence: during a marketing push that generates hundreds of reviews per hour on a few hot products, those products' FPC entries keep getting invalidated and re-rendered, and every visitor pays for the re-render until the next hit warms it.
Mitigation options:
- Accept slight staleness: reviews can lag a few minutes; the aggregation cron already introduces the same lag.
- If bursts are a real pattern, an inline/ESI-style fragment for the review summary keeps the rest of the page cached while only the rating fragment re-renders.
- Watch your Varnish hit-ratio dashboard during review-heavy campaigns — if the hot-product pages drop out of cache repeatedly, that's the symptom.
The checklist
-
Measure first:
cron_scheduledurations foraggregate_reviews, query count via the profiler on a category page with ratings, and the admin grid's load time unfiltered. - Reschedule the aggregation cron off-peak (hourly or daily) — zero storefront impact, big DB relief.
- Batch the aggregation if a single run still takes minutes.
-
Preload summaries in listings with one batched
addEntityFilter()collection instead of per-product queries. - Lazy-load long review lists on product pages.
- Add the composite indexes for status/entity and vote lookups, after staging tests.
- Keep the admin grid filtered and clean out never-approved spam rows.
Reviews are a social-proof feature with a hidden performance tax. None of these fixes change what customers see — they change what your database does four times an hour. That's the best kind of optimization: invisible, and worth more than a hundred milliseconds of TTFB tricks.
Top comments (0)