The Google Search Console report landed in my inbox on a Wednesday morning in March. The client sold scale wooden ship model kits — fully detailed replica tall-ships, brigs, and frigates in 1:48 and 1:96 scale, with hand-carved walnut hulls, brass fittings, and cotton rigging thread that retailed between £85 and £890 per kit. Their Magento 2 store had been running for three years without any meaningful technical SEO attention. The report showed 4,200 pages in the "Duplicate, Google chose different canonical than user" error state. Another 1,800 in "Crawled — currently not indexed." Their url_rewrite table had 2.3 million rows. Category page TTFB was averaging 3.1 seconds. And their Core Web Vitals report showed a field LCP of 6.8 seconds on mobile. No wonder their organic traffic had been declining at roughly 8% month-on-month for a year. None of these problems were insurmountable, but fixing them required understanding exactly how Magento 2 generates, caches, and serves its URLs — and that is where most agencies get it badly wrong.
1. The url_rewrite Table: Magento's SEO Time Bomb
The url_rewrite table is the most consequential and least understood table in a Magento 2 installation. Every product URL, every category URL, every CMS page URL, and every URL redirect is stored here. For a store with 3,000 products assigned to 40 categories, Magento generates a separate URL rewrite record for every product in every category context. A product accessible in three category paths generates three URL rewrite entries. Add multi-store views with different URL keys, and the table grows exponentially.
The ship model kit client had reached 2.3 million rows because they had: (a) 4,200 active products, (b) each product appearing in an average of 8 category paths, (c) three store views (English, German, Dutch), (d) three years of accumulated "historical redirect" rewrites generated every time a URL key was edited. Magento preserves old URL keys as 301 redirect records by default. If a product's URL key was edited five times over three years, that product has five historical rewrite records plus the current one — all stored permanently. Across 4,200 products with an average of 3 historical edits each, that is 12,600 unnecessary rows on top of the legitimate 100,000+.
-- Analyse url_rewrite table composition
SELECT
entity_type,
redirect_type,
COUNT(*) AS row_count,
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM url_rewrite), 2) AS pct
FROM url_rewrite
GROUP BY entity_type, redirect_type
ORDER BY row_count DESC;
-- Find duplicate canonical paths (products with multiple 0-redirect entries)
SELECT request_path, COUNT(*) AS count
FROM url_rewrite
WHERE redirect_type = 0
GROUP BY request_path
HAVING count > 1
ORDER BY count DESC
LIMIT 100;
-- Clean historical redirects older than 90 days that point to still-live URLs
DELETE ur FROM url_rewrite ur
INNER JOIN url_rewrite ur_current
ON ur.target_path = ur_current.request_path
AND ur_current.redirect_type = 0
WHERE ur.redirect_type = 301
AND ur.created_at < DATE_SUB(NOW(), INTERVAL 90 DAY);
Before running the DELETE — and I cannot stress this enough — take a full database backup and run the SELECT version as a query to verify the affected row count. The historical redirect rows are often legitimate from an SEO perspective: they serve users who have bookmarked an old URL or whose queries Google indexed before the URL change. Deleting them en masse will cause 404s for any external links pointing to old URLs. The correct approach is to audit which historical URLs still have active external backlinks (via Ahrefs or Search Console) and preserve those specific rows while cleaning the rest.
2. Indexer Architecture and Its SEO Consequences
Magento 2's indexer system is what generates the flat catalog tables that power storefront queries. There are roughly 15 indexers in a standard Magento 2 installation. Two are directly relevant to SEO performance: catalogsearch_fulltext (the Elasticsearch index) and catalog_url (the URL rewrite generator). The operational mode of these indexers — "Update on Save" versus "Update by Schedule" — has a dramatic effect on both storefront performance and indexation behaviour.
| Indexer | Update on Save | Update by Schedule | SEO Impact |
|---|---|---|---|
| catalog_url | Immediate URL generation, blocks admin save action | URL delays up to cron interval; may serve 404s briefly | Mismatch can create temporary soft-404s |
| catalogsearch_fulltext | Blocks product save for up to 45s on large catalogs | Search latency acceptable; no blocking | Search page relevance may lag by cron interval |
| catalog_product_price | Instant price update on PDP | Price may lag; risk of Google Merchant Center errors | Critical: stale prices in structured data |
| cataloginventory_stock | Immediate stock status | Out-of-stock products may show In Stock briefly | Medium: structured data availability mismatch |
For the ship model kit client, every indexer was running in "Update on Save" mode. With 4,200 products and a large catalog, saving a single product triggered a full catalog_url reindex that took 47 seconds — blocking the admin UI and causing a spike in database write load that degraded storefront response times simultaneously. The correct configuration is "Update by Schedule" for all indexers except catalog_product_price, which should remain on "Update on Save" to avoid Google Merchant Centre price mismatch errors. The schedule-based indexer runs via a cron job every minute, meaning the maximum URL propagation delay is 60 seconds — entirely acceptable for all but the most real-time use cases.
# Check current indexer modes via CLI
php bin/magento indexer:status
# Set all indexers to schedule mode
php bin/magento indexer:set-mode schedule
# Set price indexer back to realtime (important for GMC compliance)
php bin/magento indexer:set-mode realtime catalog_product_price
# Manually trigger a full reindex after mode change
php bin/magento indexer:reindex
# Verify cron is running (must show recent execution times)
php bin/magento cron:run
grep "catalog_url" var/log/magento.cron.log | tail -20
3. EAV vs Flat Catalog Tables: The Performance Architecture
Magento 2's product catalog uses an Entity-Attribute-Value (EAV) data model. Rather than storing all product attributes in a single wide row (as a conventional relational model would), Magento stores each attribute value in a separate row in attribute-type-specific tables (catalog_product_entity_varchar, catalog_product_entity_decimal, catalog_product_entity_text). A single product page load in a non-optimised Magento instance can generate 40–80 separate database queries to EAV tables to assemble a complete product entity.
The flat catalog feature — when enabled via Stores > Configuration > Catalog > Storefront > Use Flat Catalog Product — generates a denormalised catalog_product_flat_1 table (where 1 is the store ID) that contains all attribute values in a single wide row. This collapses those 40–80 EAV queries into a single JOIN. For a product page, this can reduce database query time from 180ms to 25ms.
-- Verify flat catalog tables exist and are populated
SHOW TABLES LIKE 'catalog_product_flat_%';
-- Compare query plans: EAV vs flat
-- EAV approach (what Magento does without flat):
EXPLAIN SELECT
e.entity_id,
v_name.value AS name,
v_price.value AS price
FROM catalog_product_entity e
JOIN catalog_product_entity_varchar v_name
ON e.entity_id = v_name.entity_id
AND v_name.attribute_id = (SELECT attribute_id FROM eav_attribute WHERE attribute_code='name' AND entity_type_id=4)
AND v_name.store_id IN (0, 1)
JOIN catalog_product_entity_decimal v_price
ON e.entity_id = v_price.entity_id
AND v_price.attribute_id = (SELECT attribute_id FROM eav_attribute WHERE attribute_code='price' AND entity_type_id=4)
WHERE e.entity_id = 12345;
-- Flat approach (single row lookup):
EXPLAIN SELECT entity_id, name, price
FROM catalog_product_flat_1
WHERE entity_id = 12345;
The flat catalog has one significant operational drawback: it requires a full rebuild after every attribute set change, which can take hours on large catalogs. For the ship model kit client with 4,200 products, the flat catalog rebuild took 22 minutes. This is not a reason to avoid it — it is a reason to schedule attribute set changes outside business hours and to monitor flat catalog rebuild status in the deployment pipeline. The performance benefit across thousands of daily product page loads far outweighs the rebuild inconvenience.
4. Core Web Vitals in Magento 2: Diagnosing and Fixing LCP
The ship model kit client's field LCP of 6.8 seconds on mobile is not unusual for an unoptimised Magento 2 installation. The Magento frontend stack — RequireJS module loading, Knockout.js for UI components, layout XML-driven template rendering — ships with a substantial JavaScript payload that blocks the main thread. Diagnosing the exact LCP element and understanding its render path is the first step before touching any configuration.
// Identify the LCP element using the Performance Observer API
// Paste in Chrome DevTools console on a product page
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP Element:', lastEntry.element);
console.log('LCP Time:', lastEntry.startTime.toFixed(0), 'ms');
console.log('LCP Size:', lastEntry.size, 'px²');
console.log('LCP URL:', lastEntry.url || 'text node');
}).observe({ type: 'largest-contentful-paint', buffered: true });
For the ship model kit client, the LCP element was the hero product image — a 2400x1800px JPEG at 1.4MB being loaded without width/height attributes and without a fetchpriority hint. The browser's preload scanner could not identify it as a priority resource because it was injected by Knockout.js after the initial HTML parse, not present in the raw HTML. The fix required three changes: (1) add a server-side rendered image tag to the page layout for above-the-fold product images, (2) add fetchpriority="high" and explicit width/height attributes, (3) convert to WebP with responsive srcset.
<!-- Before: Knockout.js injected image (invisible to preload scanner) -->
<div data-bind="html: getProductImageHtml()"></div>
<!-- After: Server-side rendered in product_image.phtml template -->
<?php
$imageUrl = $block->getImage($product, 'product_page_image_large')->getImageUrl();
$imageUrlWebp = str_replace(['.jpg','.jpeg','.png'], '.webp', $imageUrl);
?>
<picture>
<source
srcset="<?= $imageUrlWebp ?>?width=400 400w,
<?= $imageUrlWebp ?>?width=800 800w,
<?= $imageUrlWebp ?>?width=1200 1200w"
type="image/webp">
<img
src="<?= $imageUrl ?>"
width="800"
height="600"
fetchpriority="high"
loading="eager"
alt="<?= $block->escapeHtmlAttr($product->getName()) ?>"
class="gallery-placeholder__image">
</picture>
After deploying this change, the field LCP on mobile dropped from 6.8 seconds to 2.1 seconds — comfortably within Google's "Good" threshold of 2.5 seconds. The Cumulative Layout Shift score dropped from 0.41 to 0.02 because the explicit width and height attributes allowed the browser to reserve the correct space during initial render. The explicit dimensions are non-negotiable for CLS. Without them, every image on the page contributes to layout shift as it loads.
5. Structured Data in Magento 2: Customization via di.xml
Magento 2.4+ ships with native JSON-LD structured data for Product, BreadcrumbList, and Organisation schemas. However, the native implementation is frequently incomplete for real-world use cases. The Product schema often omits aggregateRating when no reviews exist rather than gracefully omitting the field, causing Google's Rich Results Test to flag an error. The offers block may not include the correct availability status for out-of-stock products. And the sku field sometimes maps to Magento's internal entity ID rather than the product's actual SKU attribute.
The correct way to extend or override Magento's structured data is via a plugin on the responsible class, not by modifying core templates. For the ship model kit client, we extended the Magento\Catalog\Block\Product\View block via a plugin to inject a corrected, fully featured Product schema.
<?php
// app/code/Modracx/Seo/Plugin/ProductStructuredData.php
namespace Modracx\Seo\Plugin;
use Magento\Catalog\Block\Product\View;
use Magento\Catalog\Model\Product;
use Magento\Review\Model\ReviewFactory;
class ProductStructuredData
{
private ReviewFactory $reviewFactory;
public function __construct(ReviewFactory $reviewFactory)
{
$this->reviewFactory = $reviewFactory;
}
public function afterToHtml(View $subject, string $html): string
{
$product = $subject->getProduct();
if (!$product || !$product->getId()) {
return $html;
}
$schema = $this->buildProductSchema($product);
$schemaTag = sprintf(
'<script type="application/ld+json">%s</script>',
json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
);
// Inject before closing body tag
return str_replace('</body>', $schemaTag . '</body>', $html);
}
private function buildProductSchema(Product $product): array
{
$inStock = $product->getExtensionAttributes()->getStockItem()?->getIsInStock();
$schema = [
'@context' => 'https://schema.org',
'@type' => 'Product',
'name' => $product->getName(),
'sku' => $product->getSku(), // Use actual SKU, not entity_id
'description' => strip_tags($product->getShortDescription() ?? ''),
'brand' => [
'@type' => 'Brand',
'name' => $product->getAttributeText('manufacturer') ?: 'MODRACX'
],
'offers' => [
'@type' => 'Offer',
'price' => number_format((float)$product->getFinalPrice(), 2, '.', ''),
'priceCurrency' => 'GBP',
'availability' => $inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
'url' => $product->getProductUrl(),
'priceValidUntil' => date('Y-12-31'),
'seller' => [
'@type' => 'Organization',
'name' => 'MODRACX Ship Models'
]
]
];
// Add aggregate rating only if reviews exist
$reviewSummary = $this->reviewFactory->create()
->getEntitySummary($product->getId());
if ($reviewSummary && $reviewSummary->getReviewsCount() > 0) {
$schema['aggregateRating'] = [
'@type' => 'AggregateRating',
'ratingValue' => number_format($reviewSummary->getRatingSummary() / 20, 1),
'reviewCount' => $reviewSummary->getReviewsCount(),
'bestRating' => '5',
'worstRating' => '1'
];
}
return $schema;
}
}
<!-- app/code/Modracx/Seo/etc/di.xml -->
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Block\Product\View">
<plugin name="modracx_seo_product_structured_data"
type="Modracx\Seo\Plugin\ProductStructuredData"
sortOrder="10" />
</type>
</config>
6. Canonical URL Management and Duplicate Content
The 4,200 pages in "Duplicate, Google chose different canonical than user" in Google Search Console had a clear cause: Magento was generating canonical tags pointing to category-context URLs (e.g., /boats/model-ships/hms-victory-kit.html) while Google was choosing the shorter, non-category URL (/hms-victory-kit.html) as the preferred canonical. This happens when a product has multiple URL paths across different category contexts and Magento is not consistently emitting the same canonical across all of them.
The fix is to standardise on a single canonical URL for each product, independent of category context. In Magento 2, this is controlled by Stores > Configuration > Catalog > Search Engine Optimization > Use Categories Path for Product URLs. Setting this to "No" means all product URLs resolve without a category prefix, and all canonical tags point to the non-category URL. This produces a single, consistent canonical URL for each product across all category landing pages where it appears.
# Magento CLI — set canonical URL configuration
php bin/magento config:set catalog/seo/product_use_categories 0
# After configuration change, reindex URL rewrites
php bin/magento indexer:reindex catalog_url
# Verify the change produced expected URL rewrites
# All product rewrites should now have no category prefix
mysql -u root -p magento -e "
SELECT request_path, target_path, redirect_type
FROM url_rewrite
WHERE entity_type = 'product'
AND redirect_type = 0
LIMIT 20;
"
After this change, re-submitted all affected URLs via Google Search Console's URL Inspection tool and requested a crawl. Within three weeks, the "Duplicate, Google chose different canonical than user" error count dropped from 4,200 to 31 — the remaining 31 being products with manually configured canonical URLs in their product attributes that contradicted the site-wide setting, which required individual review.
7. robots.txt and Sitemap Configuration
Magento 2's robots.txt is editable via Content > Design > Configuration and the sitemap is generated via Marketing > SEO & Search > Site Map. The defaults are inadequate for enterprise use. The default robots.txt does not block the /catalogsearch/ path, which allows Googlebot to crawl infinite search result pages — each one a guaranteed waste of crawl budget and a source of index bloat. The default sitemap is generated as a single file without compression, which fails for catalogs exceeding 50,000 URLs.
# Magento 2 robots.txt — optimised for SEO
# (Set via Stores > Design > Design Config > Default Store View)
User-agent: *
Disallow: /catalogsearch/
Disallow: /checkout/
Disallow: /customer/
Disallow: /sales/
Disallow: /wishlist/
Disallow: /compare/
Disallow: /review/
Disallow: /catalog/product_compare/
Disallow: /catalog/category/view/
Disallow: /*?*
Disallow: /index.php/
User-agent: Googlebot
Allow: /*.js$
Allow: /*.css$
Sitemap: https://modracx.com/sitemap.xml
The Disallow: /*?* rule blocks all parameterised URLs — including faceted navigation, pagination query strings, and tracking parameters. This is aggressive but correct for most commerce stores. The one exception is if your store uses query parameters for legitimate, indexed content (e.g., product configuration parameters for configurable products). In that case, whitelist specific parameters using the Allow directive rather than a blanket Disallow.
For the sitemap, configure split generation by enabling the "Enable Sitemap" option and setting "Maximum Number of URLs per File" to 10,000. This produces a sitemap index file pointing to multiple child sitemaps. Ensure the "Enable Compression" option is checked — compressed sitemaps are typically 80–90% smaller and ingest faster. Submit the sitemap index URL (/sitemap.xml) to Google Search Console rather than individual child sitemaps.
8. Page Speed: Deferring Non-Critical JavaScript
Magento 2's RequireJS dependency loading model loads JavaScript synchronously by default. The requirejs-config.js and associated module definitions are parsed and executed before the DOM is interactive, blocking LCP and inflating FID/INP scores. Magento 2.4.x introduced a JavaScript bundling feature via Grunt, but it is poorly understood and frequently configured incorrectly.
The practical approach for most stores is to use the defer or async attribute on non-critical scripts via Magento's layout XML and to defer Magento's full RequireJS bootstrap until after the DOM content loaded event fires for non-interactive pages (e.g., CMS pages, category listing pages where add-to-cart is below the fold).
<!-- Magento layout XML: defer non-critical scripts in default.xml -->
<!-- app/code/Modracx/Performance/view/frontend/layout/default.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<!-- Move analytics scripts to footer with defer -->
<referenceBlock name="require.js">
<arguments>
<argument name="defer" xsi:type="string">defer</argument>
</arguments>
</referenceBlock>
</body>
</page>
A more impactful optimisation for Magento 2 is enabling full-page cache (FPC) with Varnish. Without FPC, every storefront request executes the full PHP bootstrap sequence — loading the dependency injection configuration, resolving the layout XML tree, executing EAV queries, and rendering templates. With FPC enabled and Varnish configured, storefront responses for cached pages serve from Varnish memory in under 5ms. This single change typically reduces TTFB from 2–4 seconds to under 50ms for cached pages, which is the single highest-impact technical SEO optimisation available for Magento 2.
9. Magento 2 Crawl Budget: Identifying and Closing Leaks
For the ship model kit client, the "Crawled — currently not indexed" count of 1,800 URLs was caused by four distinct URL patterns that Magento was generating and Googlebot was crawling but refusing to index due to thin content or near-duplication. Diagnosing crawl budget leaks requires correlating Google Search Console's crawl stats report with server access logs parsed by CDN provider.
-- Identify URL patterns generating crawl budget waste
-- (Run against server access log imported to a MySQL table or use Athena on S3 logs)
SELECT
REGEXP_REPLACE(request_uri, '\\?.*$', '') AS clean_path,
COUNT(*) AS crawl_hits,
COUNT(DISTINCT DATE(request_time)) AS days_crawled,
AVG(response_time_ms) AS avg_ttfb_ms,
SUM(CASE WHEN http_status = 200 THEN 1 ELSE 0 END) AS hits_200,
SUM(CASE WHEN http_status = 404 THEN 1 ELSE 0 END) AS hits_404
FROM access_logs
WHERE user_agent LIKE '%Googlebot%'
AND request_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
GROUP BY clean_path
HAVING crawl_hits > 10
ORDER BY crawl_hits DESC
LIMIT 100;
The query above consistently reveals the same categories of waste: the /catalog/category/view/id/ internal Magento routes (which should be disallowed in robots.txt), pagination URLs for category pages beyond page 3 or 4 (where product counts drop to low single digits), sorting parameter variants (?dir=asc, ?dir=desc, ?order=name), and CMS block preview URLs. Each of these patterns needs a corresponding robots.txt Disallow or a noindex meta robots tag depending on whether the URL pattern represents genuinely indexable content on some pages but not others.
10. Hreflang in Multi-Store Magento 2
For the ship model kit client with three store views (English, German, Dutch), hreflang implementation was missing entirely. Magento 2 does not generate hreflang tags natively. They must be added via a custom module or a third-party extension. The implementation must handle the multi-store URL structure where each store view may have a different URL prefix (/de/, /nl/) or a different domain (modracx.de, modracx.nl).
<?php
// Simplified hreflang injection for Magento 2 multi-store
// app/code/Modracx/Seo/Block/Hreflang.php
namespace Modracx\Seo\Block;
use Magento\Framework\View\Element\AbstractBlock;
use Magento\Store\Model\StoreManagerInterface;
use Magento\Framework\Registry;
class Hreflang extends AbstractBlock
{
private StoreManagerInterface $storeManager;
private Registry $registry;
// Store view to hreflang locale mapping
private array $localeMap = [
1 => 'en-GB',
2 => 'de-DE',
3 => 'nl-NL',
];
public function __construct(
StoreManagerInterface $storeManager,
Registry $registry,
\Magento\Framework\View\Element\Context $context,
array $data = []
) {
$this->storeManager = $storeManager;
$this->registry = $registry;
parent::__construct($context, $data);
}
public function getHreflangTags(): array
{
$product = $this->registry->registry('current_product');
$tags = [];
foreach ($this->storeManager->getStores() as $store) {
$locale = $this->localeMap[$store->getId()] ?? null;
if (!$locale) {
continue;
}
// Get the product URL in this store's context
$productUrl = $product
? $product->setStoreId($store->getId())->getUrlModel()->getUrl($product)
: $store->getBaseUrl();
$tags[] = [
'hreflang' => $locale,
'href' => $productUrl
];
}
// Add x-default pointing to default store
$tags[] = ['hreflang' => 'x-default', 'href' => $tags[0]['href'] ?? ''];
return $tags;
}
}
Frequently Asked Questions
How large should a Magento 2 url_rewrite table be before it becomes a performance concern?
Above 500,000 rows, you will start seeing measurable query time increases on the rewrite resolution queries that execute on every non-cached storefront request. Above 2 million rows, index seeks on the table can add 50–200ms to TTFB on cold requests. The primary index on request_path is a string column, making it more expensive to scan than integer indexes. If your table exceeds 1 million rows, audit and clean historical redirect rows first before adding additional database indexes.
Should I enable the flat catalog if I have configurable products?
Yes, but with awareness. The flat catalog stores simple product data. Configurable product super-attribute data and child product associations are stored separately and are not affected by flat catalog status. Enabling flat catalog will improve simple product queries and the basic listing data for configurables. The child product variant data (swatches, size availability) still queries EAV tables. For stores with predominantly configurable products, pair flat catalog with thorough Redis caching of the variant data to avoid repeated EAV lookups for swatches and stock status.
What is the correct way to handle pagination for SEO in Magento 2?
Use rel="next" and rel="prev" annotations on paginated series, and ensure that page 1 of a category has a canonical pointing to the base category URL (not /category.html?p=1). Google officially deprecated rel-next/prev in 2019, but it still provides a useful signal to other search engines. The more important action is ensuring the paginated URLs are not generating thin content — if page 15 of a category only has 3 products, it should return a noindex meta tag or redirect to the base category URL.
How do I prevent Magento's search results pages from consuming crawl budget?
Add Disallow: /catalogsearch/ to robots.txt. This blocks all URLs under the catalogsearch path. Additionally, add a <meta name="robots" content="noindex,nofollow"> tag to the search results page template (catalogsearch/result/index.phtml) as a belt-and-suspenders measure. Search result pages have no stable content, duplicate product listings from category pages, and are near-infinite in permutation — they are the single largest source of crawl budget waste in a default Magento 2 installation.
Does Magento 2's built-in structured data meet Google's current requirements?
Partially. Magento 2.4.x ships with basic Product schema including name, sku, description, image, and offers. It does not include aggregateRating (requires review data integration), brand (requires custom attribute mapping), or returnPolicy (required for enhanced Google Shopping listings). For Google Merchant Centre integration and rich snippet eligibility, you will almost always need a custom structured data module that extends the native implementation via di.xml plugins rather than replacing it entirely.
What is the impact of Magento 2's JavaScript on INP (Interaction to Next Paint)?
Significant. Magento 2's Knockout.js UI components bind event handlers to DOM elements after a JavaScript execution chain that can take 2–4 seconds on mid-range mobile devices. Until those handlers are bound, user interactions (clicking add-to-cart, opening product gallery) either do nothing or are queued. This produces INP scores in the 400–800ms range on unoptimised Magento 2 stores — well above Google's 200ms threshold. The fix requires deferring non-critical UI component initialisation, using intersection observers to lazy-initialise below-the-fold components, and splitting the RequireJS bundle to load only the components needed for above-the-fold interactivity on initial render.
How often should I run a full reindex in Magento 2?
Full reindexes should be the exception, not the routine. With schedule-based indexing, Magento incrementally updates index tables every minute. Full reindexes are required after: installing or upgrading modules that add new attributes, changing attribute sets, enabling or disabling the flat catalog, and after major data migrations. In a healthy production environment with schedule-based indexing, full reindexes should occur at most once per quarter, typically during planned maintenance windows.
What Magento 2 SEO configuration changes have the highest immediate impact?
In order of impact: (1) Enable Varnish FPC — single most impactful change for TTFB and crawl budget. (2) Disable "Use Categories Path for Product URLs" — eliminates the canonical mismatch problem affecting thousands of pages. (3) Switch indexers to schedule mode except catalog_product_price — removes admin blocking and reduces database write spikes. (4) Add explicit disallows for catalogsearch, pagination, and filter parameters in robots.txt — immediately reduces crawl budget waste. (5) Clean the url_rewrite table of historical redirect rows — reduces query time on rewrite lookups.
Can I use a third-party SEO extension alongside custom di.xml plugins safely?
Yes, but carefully. Third-party SEO extensions (SEO Suite Ultimate, Mageworx, Amasty SEO) often register their own plugins on the same classes you might target with custom code. Plugin sort order determines execution sequence. Set your custom plugin's sortOrder higher (e.g., 200) than the third-party plugin (typically defaults to 10) so your code runs after theirs, allowing you to override or supplement their output. Review the extension's di.xml to understand exactly which methods it intercepts before writing your own plugin.
11. Elasticsearch and OpenSearch Configuration for SEO-Relevant Search Quality
Magento 2.4+ replaced the MySQL-based catalogsearch with Elasticsearch (now OpenSearch) as the mandatory search engine. The configuration of the search index directly affects both on-site search quality and the SEO behaviour of search result pages. Poor search relevance leads to users finding search pages through organic search and immediately bouncing when the results do not match their intent — a strong negative signal to Google.
The default Magento 2 Elasticsearch configuration applies equal weight to all searchable attributes. This means a search for "HMS Victory 1:96 kit" gives equal weight to the product name, the long description, the meta keywords, and any custom attributes you have made searchable. In practice, name and SKU should carry dramatically higher weight than description text. A result where a product's description mentions HMS Victory incidentally should rank below a product whose name is "HMS Victory Ship Model Kit 1:96 Scale".
# Magento 2 search weight configuration via admin
# Stores > Configuration > Catalog > Catalog Search
# Alternatively, configure via CLI for automation:
php bin/magento config:set catalog/search/engine opensearch
php bin/magento config:set catalog/search/opensearch_server_hostname localhost
php bin/magento config:set catalog/search/opensearch_server_port 9200
php bin/magento config:set catalog/search/opensearch_index_prefix magento2
# After configuration, run a full search reindex
php bin/magento indexer:reindex catalogsearch_fulltext
# Verify the index was created successfully
curl -X GET "localhost:9200/_cat/indices?v&index=magento2*"
For search attribute weights, navigate to Stores > Attributes > Product and edit each searchable attribute. The "Search Weight" field (1–10) controls the Elasticsearch boost factor. Name should be at 10. SKU at 8. Short description at 6. Long description at 4. Meta keywords should be set to 1 or removed from the search index entirely — they are frequently stuffed with comma-separated terms that distort relevance and produce low-quality search results. The ship model kit client had meta keywords set to weight 5, which was causing their category pages to rank above specific product PDPs in on-site search — the exact opposite of the intended behaviour.
12. Magento 2 SEO Technical Audit Checklist
Every Magento 2 SEO engagement should begin with a structured audit against the same reference checklist. This ensures nothing is missed and provides a baseline for measuring improvement. The checklist below represents the items I verify on every new client engagement, ordered by impact.
| # | Check | Tool / Method | Impact |
|---|---|---|---|
| 1 | Full Page Cache (Varnish) enabled and warm | curl -I https://domain.com | grep X-Magento-Cache |
Critical — TTFB |
| 2 | url_rewrite table row count below 500k | MySQL: SELECT COUNT(*) FROM url_rewrite |
High — query latency |
| 3 | Categories path disabled for product URLs | Admin: Stores > Config > SEO > Use Categories Path | High — canonical consistency |
| 4 | robots.txt blocks catalogsearch and filter URLs | Fetch https://domain.com/robots.txt | High — crawl budget |
| 5 | Sitemap generated with compression, split by 10k URLs | Admin: Marketing > Site Map settings | High — indexation |
| 6 | Product schema includes correct availability and SKU | Google Rich Results Test on 5 PDPs | High — rich snippets |
| 7 | Hero product image uses explicit width/height attributes | Chrome DevTools > Performance > LCP | High — Core Web Vitals LCP |
| 8 | Indexers in schedule mode (except product price) | php bin/magento indexer:status |
Medium — admin performance |
| 9 | Flat catalog enabled | Admin: Stores > Config > Catalog > Use Flat Catalog | Medium — DB query performance |
| 10 | hreflang tags present and reciprocal (multi-store only) | View source on each store view's equivalent page | Medium — international SEO |
| 11 | Elasticsearch/OpenSearch search weights configured | Admin: Stores > Attributes > Product > Search Weight | Medium — search quality |
| 12 | Google Search Console shows no crawl anomalies in past 90 days | GSC > Pages > Not indexed | Ongoing monitoring |
Run through this checklist quarterly. The url_rewrite row count, crawl budget status in Google Search Console, and Core Web Vitals field data change over time — often degrading without a single identifiable code change, simply as a consequence of catalog growth, content editing activity, and search engine re-evaluation of the site's content quality. A quarterly audit that catches a growing url_rewrite table at 700k rows is far less painful than discovering it at 3 million rows after organic traffic has already declined for six months.