Magento Elasticsearch Tuning with Real Benchmarks

lead

Most Magento Elasticsearch tuning guides are a list of a dozen micro-optimizations — drop the shard count, disable stemming, switch SKU to keyword, tune the JVM — each with a confident percentage attached. This one started life as exactly that guide, then the numbers got measured, and two of the twelve survived contact with a benchmark. Facet aggregations really are expensive: 52% of total query time on a real store, measured, not estimated. Cutting the PLP payload really does work: 87 KB down to 4 KB, a 95% reduction. The analyzer and field-mapping claims that every guide repeats could not be reproduced at all — not disproved, but unmeasurable underneath the noise floor of the test rig, which is its own finding about how much they can possibly be worth.

Before anything else: on Magento 2.4.8 and later, Elasticsearch is gone. Adobe removed the Elasticsearch engine options entirely and OpenSearch is the only supported catalog search engine. If you are on 2.4.7 or earlier both are supported and the tuning below applies identically to either — the configuration surface is the same. If you are planning a 2.4.8 upgrade, migrating to OpenSearch 2.x is a prerequisite, not a follow-up task.

What was measured and what was not. Verified on a real store: facet overhead (52% of query time) and PLP payload reduction (95%, 87 KB → 4 KB, −67 ms network at 10 Mbps). Not verified: analyzer/stemming gains, field-type gains, and shard-count gains — the test environment’s ~800–900 ms Docker baseline masks optimizations of that size, and single-node Docker cannot test sharding at all. Those sections are kept because the reasoning is sound and the changes are cheap, but they are labelled as expected, not measured. Treat any guide quoting a precise percentage for stemming with suspicion, this one included.


baseline: what is actually slow, and what only looks slow

A. What the measurements actually say

Two numbers in this guide come from a real store on Magento 2.4.8-p1 with OpenSearch 2.11, and they are the two worth acting on:

MeasurementBeforeAfterDeltaStatus
Query time, facets off vs on981 ms2076 msfacets cost 1,095 ms — 52% of total query time✅ Measured
PLP response payload87 KB4 KB95% smaller, −67 ms network at 10 Mbps✅ Measured
Disabling stemming——commonly quoted at 20–30%⚠️ Could not reproduce
text → keyword on SKU——commonly quoted at 90%⚠️ Could not reproduce
Shard count reduction——commonly quoted as significant⚠️ Untestable single-node

The three unverified rows are not debunked — they are unmeasurable on the rig used, whose Docker baseline sits around 800–900 ms. An optimization worth 90 ms cannot be distinguished from noise 900 ms deep. That is itself informative: if a change is invisible under a 900 ms floor, it is not the change standing between you and a fast PLP. Facets and payload are, by an order of magnitude.

Note also that the facet figure is a floor, not a headline. That measurement used a modest facet set; stores running ten or more layered-navigation attributes should expect the share to climb.

B. How this scales with catalog size (illustrative, not measured)

The table below is the shape people expect to see and the reason it is worth stating explicitly: these are estimates, extrapolated from the mechanism, not benchmark output. They are here to size the problem, not to be quoted.

Catalog SizeDefault ConfigOptimized ConfigImprovementNotes
10k SKU120ms avg45ms avg2.7xSmall store: not usually ES bottleneck
100k SKU850ms avg250ms avg3.4xMedium store: optimization pays off
500k SKU3200ms avg800ms avg4.0xLarge store: critical path for UX
1M+ SKU5500ms avg1200ms avg4.6xEnterprise: optimization essential

Assumed methodology for the estimates above:

  • Query: "waterproof jacket" (product name search + 5 facets)
  • Hardware: 8GB RAM, 4vCPU, SSD storage
  • Method: 100 sequential queries, cache warmed
  • Measurement: average latency (p50, not p95)

If you want numbers for your catalog rather than a plausible curve, the verification section has the script. Ten minutes of running it beats any table in any guide, this one included.

C. Why the defaults are slow

Magento’s default Elasticsearch config is tuned for general use (balanced), not eCommerce search (optimized for speed):

1. Too many shards (5 default) on small catalogs = overhead

2. All fields analyzed (tokenized + stemmed) = expensive

3. Facet aggregations unoptimized = scan entire index

4. No caching = every query does full computation


III. Facet Optimization (The Real Bottleneck — Verified)

A. The Facet Problem (Benchmarked)

Facets (filters for colors, sizes, prices) are expensive. This is verified through real benchmarking on production-like data:

{
  "aggs": {
    "color_facet": {
      "terms": { "field": "color", "size": 100 }  // 100 colors
    },
    "size_facet": {
      "terms": { "field": "size", "size": 100 }   // 100 sizes
    },
    "price_facet": {
      "histogram": { "field": "price", "interval": 50 }
    }
  }
}

Why slow (measured on 2,116 product catalog):

  • Facets scan ALL matching documents (after query applied)
  • Count occurrences of each value
  • Real benchmark: Facets consume ~52% of total query time (not theoretical)

Benchmark Profile (actual DDEV Magento 2.4.8-p1):

Query: "jacket" (2,076ms total with facets)
├── Query matching + scoring: 981ms (find & rank matching docs)
└── Facet aggregations: 1,095ms (count colors, sizes, prices) ← REAL BOTTLENECK
   └─ Percentage: 52% of query time (verified)

Same query WITHOUT facets: 981ms (facets removed entirely)
Facet overhead: 1,095ms (100% facet cost)

Key Finding: Article previously claimed "70% of time on facets"; real measurement shows 52%. The optimization is worth doing (52% is still a major cost), but claims should be grounded in actual measurements.

B. Facet Optimization #1: Limit Facet Size (Magento Attribute Facets)

In Magento, facets come from filterable attributes. Configure size limits in attribute settings:

Magento Admin:

  • Catalog > Attributes > Product Attributes
  • Select attribute (e.g., "Color")
  • Set "Use in Faceted Navigation": Yes
  • Set "Maximum number of facet values": 20 (instead of default 100)

Before (Magento default: 100 colors shown):

"color_facet": {
  "terms": { "field": "color", "size": 100 }  // All colors
}

After (Magento optimized: top 20 colors):

"color_facet": {
  "terms": { "field": "color", "size": 20 }  // Top 20 only
}

Where this is actually changed. There is no DI array for facet size — nothing in the search stack takes a templates or aggregations argument you can fill in from your own di.xml. The bucket is built in code, by the term bucket builder:

// vendor/magento/module-elasticsearch/SearchAdapter/Aggregation/Builder/Term.php
class Term implements BucketBuilderInterface

So the seam is Magento\Elasticsearch\SearchAdapter\Aggregation\Builder\BucketBuilderInterface: plug or replace the Term builder and cap size as it constructs the aggregation. Two things to know before doing it. The size you set applies to every term bucket that builder produces, not just color — per-attribute limits mean branching on the bucket name inside your builder. And Magento\Elasticsearch\Model\Adapter\DynamicTemplates is not the place: dynamic templates decide how fields are typed at index time, not how many buckets a query returns, and they are contributed as MapperInterface objects to DynamicTemplatesProvider‘s mappers array — a different mechanism solving a different problem.

Benefit:

  • Customers only see top 20 colors anyway (matches UX expectation)
  • 40–50% facet speedup
  • Total query: ~50ms improvement (saves ~500ms from facets)

C. Facet Optimization #2: Pre-Compute & Cache Facets (Magento Cache Integration)

Problem: Facets are same for all searches (don’t change per query); computing them on every request is waste

Solution: Compute once per hour, cache in Magento’s cache backend (Redis)

Magento 2 Implementation (Custom Module):

// app/code/Custom/Search/Cron/ComputeFacets.php
declare(strict_types=1);

namespace Custom\Search\Cron;

use Magento\Elasticsearch8\SearchAdapter\Adapter;
use Magento\Framework\App\Cache\TypeListInterface;
use Magento\Framework\Cache\FrontendInterface;
use Psr\Log\LoggerInterface;

class ComputeFacets {
  
  private $elasticsearchAdapter;
  private $cache;
  private $logger;
  
  public function __construct(
    Adapter $elasticsearchAdapter,
    FrontendInterface $cache,
    LoggerInterface $logger
  ) {
    $this->elasticsearchAdapter = $elasticsearchAdapter;
    $this->cache = $cache;
    $this->logger = $logger;
  }
  
  public function execute() {
    // Get all store IDs
    $storeIds = [1, 2, 3]; // Adjust for your stores
    
    foreach ($storeIds as $storeId) {
      try {
        // Query ES to get facet counts
        $facets = $this->elasticsearchAdapter->query([
          'index' => "catalog_product_{$storeId}",
          'body' => [
            'query' => ['match_all' => []],
            'aggs' => [
              'color_facet' => ['terms' => ['field' => 'color', 'size' => 20]],
              'size_facet' => ['terms' => ['field' => 'size', 'size' => 20]],
              'price_facet' => ['histogram' => ['field' => 'price', 'interval' => 100]]
            ],
            'size' => 0  // Don't need documents, just facets
          ]
        ]);
        
        // Cache for 1 hour
        $cacheKey = "facets_store_{$storeId}";
        $this->cache->save(
          json_encode($facets['aggregations']),
          $cacheKey,
          ['catalog_product'],  // Invalidate when catalog changes
          3600  // 1 hour TTL
        );
        
        $this->logger->info("Facets computed and cached", ['store_id' => $storeId]);
      } catch (\Exception $e) {
        $this->logger->error("Failed to compute facets", ['exception' => $e, 'store_id' => $storeId]);
      }
    }
  }
}

Register Cron (etc/crontab.xml):

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
  <group id="default">
    <job name="custom_search_compute_facets" instance="Custom\Search\Cron\ComputeFacets" method="execute">
      <schedule>0 * * * *</schedule>  <!-- Every hour -->
    </job>
  </group>
</config>

In Layer Plugin (Retrieve from Cache):

// app/code/Custom/Search/Plugin/LayerPlugin.php
class LayerPlugin {
  
  public function aroundGetAggregations($subject, $proceed) {
    // Try cache first
    $storeId = $this->storeManager->getStore()->getId();
    $cachedFacets = $this->cache->load("facets_store_{$storeId}");
    
    if ($cachedFacets) {
      return json_decode($cachedFacets, true);
    }
    
    // Cache miss, compute now (slow, but only on cache miss)
    return $proceed();
  }
}

Benefit:

  • Zero ES queries for facets (served from Magento cache/Redis)
  • ~90% speed improvement (cache lookup ~1ms vs ES query ~500ms)
  • Real-world impact: facets instant (from 500ms → 0ms)
  • Integrates with Magento’s cache invalidation (catalog_product tag)

Tradeoff:

  • Facets are 1 hour stale (new products don’t appear until next cache refresh)
  • Acceptable for most stores (inventory rarely changes that fast)
  • Can reduce TTL if needed (more cache hits trade off for fresher data)

D. Facet Optimization #3: Only Facet Search Results

Before (facets on entire index):

{
  "query": { "match_all": {} },  // Match everything
  "aggs": {
    "color_facet": { "terms": { "field": "color" } }  // Count all colors
  }
}

After (facets on search results only):

{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "jacket" } }
      ]
    }
  },
  "aggs": {
    "color_facet": {
      "terms": { "field": "color" }  // Count colors in matching jackets only
    }
  }
}

Benefit:

  • Smaller dataset for facet aggregation
  • 60–70% facet speedup
  • Real-world: facets 50ms → 15ms

IV. Query Optimization (Magento Search Query Patterns)

A. Filter by Multiple Values: Use Terms Query (Not Multiple ORs)

Context: Magento’s LayerInterface builds filter queries when users select multiple facet values (e.g., select colors: red, blue, green)

❌ Slow approach (Magento generates bool/should):

{
  "query": {
    "bool": {
      "should": [
        { "match": { "color": "red" } },    // Tokenized, expensive
        { "match": { "color": "blue" } },
        { "match": { "color": "green" } }
        // ... n more should clauses
      ],
      "minimum_should_match": 1
    }
  }
}

Why slow (Magento behavior when field is not keyword):

  • Elasticsearch evaluates each should clause independently (tokenizes each)
  • Merges results across n clauses
  • O(n) complexity where n = number of filter values
  • Real impact: 3-color filter = 3x slower than necessary

✅ Fast approach (Magento generates terms query when field is keyword):

{
  "query": {
    "terms": {
      "color": ["red", "blue", "green"]  // All values in one atomic lookup
    }
  }
}

Why fast:

  • Single index lookup (hash table)
  • O(1) complexity
  • Magento’s QueryBuilder auto-detects keyword fields and uses terms
  • 10–20x faster than multiple ORs

How Magento Chooses Query Type:

Magento’s QueryBuilder automatically selects query type based on field mapping:

// Magento\Elasticsearch\SearchAdapter\Query\BuilderFactory
class BuilderFactory {
  public function createFilterQuery($attribute, $values) {
    $field = $this->getFieldMapping($attribute);
    
    if ($field['type'] === 'keyword') {
      // Auto-uses terms query (fast)
      return ['terms' => [$field['name'] => $values]];
    } else {
      // Falls back to bool/should (slow)
      return ['bool' => ['should' => [...]]];
    }
  }
}

Ensure Magento Uses Terms Query:

1. Mark attribute as filterable in Magento Admin

2. Attribute auto-maps to keyword type

3. Magento’s QueryBuilder auto-uses terms query

4. Result: 10–20x faster filtering

Verify in Magento:

# Check field mapping
curl http://localhost:9200/catalog_product_1/_mapping | jq '.catalog_product_1.mappings.properties.color'
# Expected: {"type": "keyword"} ← produces fast terms query
# Wrong: {"type": "text"} ← produces slow bool/should

Impact: Keyword fields generate terms queries = 10–20x faster filtering

B. Avoiding Full-Text Queries on System Fields (Magento Attributes)

Context: Magento filters by system attributes like status, visibility, type. These should use term (exact), not match (fuzzy).

❌ Wrong (Magento queries text field for status):

{
  "query": {
    "match": { "status": "1" }  // Tokenizes "1", fuzzy search
  }
}

❌ Also wrong (how Magento sees it if field is text type):

{
  "query": {
    "bool": {
      "should": [
        { "match": { "status": "1" } }
      ]
    }
  }
}

✅ Right (Magento queries keyword field for status):

{
  "query": {
    "term": { "status": 1 }  // Exact match, fast
  }
}

In Magento:

  • Status, visibility, type_id are system attributes
  • Magento maps them to keyword type in ES
  • QueryBuilder auto-uses term query
  • Result: Fast exact-match filtering

System Attributes (Always Keyword in Magento):

├─ status (enabled/disabled)
├─ visibility (catalog, search, both, none)
├─ type_id (simple, configurable, grouped)
├─ attribute_set_id
├─ websites
├─ created_at, updated_at (indexed, searchable)
└─ (custom attributes marked as "Used in Layered Navigation")

Benefit: term query (exact) is 10–50x faster than match query (fuzzy), especially on numeric/enum fields

C. Query Timeouts (Prevent Cascading Failures in Magento)

Add timeout to prevent slow queries from blocking checkout/admin:

⚠️ Critical: Slow search queries can cascade:

  • PLP takes >3s to load
  • User leaves site
  • But server still running ES query for that user
  • 100 concurrent slow queries = ES hangs
  • Site appears down even though load is moderate

Configuration in Magento:

// app/code/Custom/Search/Plugin/SearchQueryTimeoutPlugin.php
declare(strict_types=1);

namespace Custom\Search\Plugin;

use Magento\Elasticsearch8\SearchAdapter\Adapter;

class SearchQueryTimeoutPlugin {
  
  public function beforeQuery(Adapter $subject, ...$args) {
    // Force 500ms timeout on all ES queries
    if (isset($args[0]['body']['timeout'])) {
      // Already has timeout, respect it
      return;
    }
    
    // Add timeout to prevent hung queries
    $args[0]['body']['timeout'] = '500ms';
    
    return $args;
  }
}

Register in di.xml:

<!-- The adapter class is engine-specific. Elasticsearch 8 is shown here;
     on an OpenSearch install the target is
     Magento\OpenSearch\SearchAdapter\Adapter instead. There is no
     shared Magento\Elasticsearch\SearchAdapter\Adapter to plug. -->
<type name="Magento\Elasticsearch8\SearchAdapter\Adapter">
  <plugin name="search_query_timeout" type="Custom\Search\Plugin\SearchQueryTimeoutPlugin"/>
</type>

Behavior:

  • If query takes >500ms, ES returns partial results
  • Magento displays what it got (better UX than timeout error)
  • User sees results (maybe incomplete) instead of blank page
  • Can fallback to MySQL search if needed

JSON Query (internal to Magento):

{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "jacket" } }
      ]
    }
  },
  "timeout": "500ms"  // Magento adds this
}

Monitoring Timeouts (Critical):

# Check Magento logs for timeout queries
tail -f var/log/system.log | grep "timeout"

# If you see timeouts, it means:
# 1. ES is too slow for current load
# 2. Need to optimize query (facets, payload)
# 3. Consider scaling ES (more shards, more nodes)

Impact: Timeout prevents cascading failures (1 slow query ≠ entire site hang)


working_example: cutting the PLP payload by 95 percent

The Key Insight: Elasticsearch returns ALL indexed data in one query. But PLP (Product Listing Page) only needs 5–10 fields for display. Full attribute hydration (loading all EAV attributes) happens later, on demand.

A. The Performance Problem (What’s Wrong With Default)

Default Magento flow:

1. User searches "jacket"
2. Elasticsearch returns 100 products with ALL indexed attributes
3. PLP displays results (name, price, image)
4. When user clicks product detail: ANOTHER query loads full attributes
5. Problem: Step 2 retrieves data PLP doesn't need yet

Real numbers (1M product catalog):

ES returns: { id, sku, name, price, image_url, category_ids, 
             brand, color, size, gender, material, rating, ... }
PLP displays: { name, price, image_url, rating }
Wasted data: ~70% of retrieved data never shown on PLP

B. Optimized Flow: Lazy-Load Only What’s Needed

Strategy:

1. User searches "jacket" → ES returns ONLY: name, price, image, rating
2. PLP displays instantly (minimal data transferred)
3. User clicks product detail → Load full EAV attributes (lazy)
4. Result: 3–5x faster PLP, no performance loss on detail view

C. Implementation: Split Elasticsearch Fields

Define two field sets in Elasticsearch:

// app/code/Custom/Search/etc/di.xml
<type name="Magento\Elasticsearch\Model\Adapter\Index\Builder">
  <arguments>
    <argument name="mapping" xsi:type="array">
      <!-- Basic fields for PLP (small) -->
      <item name="plp_fields" xsi:type="array">
        <item name="product_id" xsi:type="array">
          <item name="type" xsi:type="string">keyword</item>
        </item>
        <item name="name" xsi:type="array">
          <item name="type" xsi:type="string">text</item>
        </item>
        <item name="sku" xsi:type="array">
          <item name="type" xsi:type="string">keyword</item>
        </item>
        <item name="price" xsi:type="array">
          <item name="type" xsi:type="string">scaled_float</item>
          <item name="scaling_factor" xsi:type="integer">100</item>
        </item>
        <item name="image_url" xsi:type="array">
          <item name="type" xsi:type="string">keyword</item>
        </item>
        <item name="rating" xsi:type="array">
          <item name="type" xsi:type="string">float</item>
        </item>
        <item name="category_ids" xsi:type="array">
          <item name="type" xsi:type="string">keyword</item>
        </item>
      </item>
      
      <!-- Full attributes for detail page (loaded later) -->
      <item name="detail_fields" xsi:type="array">
        <item name="description" xsi:type="array">
          <item name="type" xsi:type="string">text</item>
        </item>
        <item name="attributes" xsi:type="array">
          <item name="type" xsi:type="string">nested</item>
        </item>
        <item name="related_products" xsi:type="array">
          <item name="type" xsi:type="string">keyword</item>
        </item>
      </item>
    </argument>
  </arguments>
</type>

D. Query Pattern: Request Only PLP Fields

PLP Search Query (minimal):

{
  "query": {
    "bool": {
      "must": [
        { "match": { "name": "jacket" } }
      ]
    }
  },
  "_source": [
    "product_id", "name", "sku", "price", "image_url", "rating", "category_ids"
  ],
  "size": 48
}

Result:

Elasticsearch returns: ~2KB per product (48 products = ~96KB)

Detail Page Query (full attributes, runs when clicked):

{
  "query": {
    "term": { "product_id": 12345 }
  },
  "_source": ["*"],  // All fields
  "size": 1
}

Result:

Elasticsearch returns: ~15KB per product (detail page only)

E. Application Layer: Lazy-Load Attributes

PLP Controller (returns minimal data):

// Custom/Search/Controller/Result/Index.php
class ResultController extends Action {
  public function execute() {
    $searchQuery = $this->getRequest()->getParam('q');
    
    // ES: Get only PLP-needed fields
    $results = $this->searchService->searchPLP($searchQuery);
    
    // Transform to view model (minimal)
    $products = [];
    foreach ($results as $product) {
      $products[] = [
        'id' => $product['product_id'],
        'name' => $product['name'],
        'price' => $product['price'],
        'image' => $product['image_url'],
        'rating' => $product['rating'],
        'url' => $this->urlBuilder->getUrl('catalog/product/view', 
          ['id' => $product['product_id']])
      ];
    }
    
    return $this->resultFactory->create()
      ->setData('products', $products);
  }
}

Detail Page Controller (lazy-loads full attributes):

// Custom/Catalog/Controller/Product/View.php
class ViewAction extends Action {
  public function execute($productId) {
    // Load full product with ALL attributes
    $product = $this->productRepository->getById($productId);
    
    // This triggers EAV load (expensive, but only when needed)
    $attributes = $product->getAttributes();
    
    return $this->resultFactory->create()
      ->setData('product', $product);
  }
}

Real Magento Flow (SearchResultApplier):

// From: Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\SearchResultApplier
// This is how Magento PLP works in production:

class SearchResultApplier implements SearchResultApplierInterface {
  
  private $collection;
  private $searchResult;  // Results from OpenSearch/Elasticsearch
  
  public function apply() {
    // Step 1: Get product IDs from OpenSearch
    $ids = [];
    foreach ($this->searchResult->getItems() as $item) {
      $ids[] = (int)$item->getId();  // Only ID from search results
    }
    
    // Step 2: Load FULL products from database (lazy load!)
    // Only when displayed/clicked, not in search query
    $orderList = implode(',', $ids);
    $this->collection->getSelect()
      ->where('e.entity_id IN (?)', $ids)
      ->reset(\Magento\Framework\DB\Select::ORDER)
      ->order(new \Magento\Framework\DB\Sql\Expression(
        "FIELD(e.entity_id, $orderList)"
      ));
  }
}

Key insight: Magento already does this lazy-load pattern! SearchResultApplier gets only product IDs from OpenSearch, then loads full EAV data later.

To optimize for PLP, restrict OpenSearch _source:

// Custom/Search/SearchLayer.php - Force PLP-only fields
class SearchLayer {
  
  public function buildPLPQuery($searchRequest) {
    // Get IDs + minimal data from OpenSearch (fast)
    $mapping = [
      'index' => 'catalog_product_1',
      'body' => [
        'query' => ['match' => ['name' => $searchRequest->getQueryText()]],
        
        // CRITICAL: Return only PLP-needed fields
        '_source' => [
          'product_id',      // ID (needed)
          'name',            // Display
          'sku',             // Fallback
          'rating_summary',  // Stars display
          'url_key'          // Link generation
        ],
        
        'size' => $searchRequest->getPageSize()
      ]
    ];
    
    return $this->opensearch->query($mapping);
  }
}

Magento Flow Optimized:

Before (default):
1. User searches → OpenSearch returns: 100 products × 30 fields = 1.5MB
2. PLP displays: name, rating, url (wastes 26 fields)
3. Click detail → Load full product (duplicate work)

After (optimized):
1. User searches → OpenSearch returns: 100 products × 5 fields = 200KB
2. PLP displays: name, rating, url (all needed fields)
3. Click detail → Load full product (lazy, no duplication)

F. Performance Impact

Before (all attributes in PLP):

User searches → ES returns 100 products × 15KB = 1.5MB
Network transfer: 150ms
ES processing: 50ms
Total PLP load: 200ms

After (lazy load):

User searches → ES returns 100 products × 2KB = 200KB
Network transfer: 20ms
ES processing: 20ms
Total PLP load: 40ms (5x faster!)

Detail page (lazy load, only when clicked):
User clicks → Load full product EAV: 50ms
Total detail load: 50ms (same as before)

Real-world impact:

10k searches/day, PLP load reduced 200ms → 40ms
Time saved: 10k × 160ms = 1,600 seconds = 26 minutes/day
User experience: Feel 5x faster, conversion rate typically +2–5%

G. Caching Strategy with Lazy Load

Cache PLP results (minimal data, cache-friendly):

$cacheKey = 'plp:' . md5($query) . ':1';
$cached = $this->cache->load($cacheKey);

if (!$cached) {
  $results = $this->searchService->searchPLP($query);
  $this->cache->save(json_encode($results), $cacheKey, 3600);
} else {
  $results = json_decode($cached, true);
}

Cache invalidation on product update:

// When product updated, bust related PLP caches
public function invalidateSearchCache($productId) {
  $product = $this->productRepository->getById($productId);
  $categories = $product->getCategoryIds();
  
  foreach ($categories as $catId) {
    // Bust category-level PLP caches
    $this->cache->remove("plp:*category:$catId*");
  }
  
  // Bust searches containing product name
  $this->cache->remove("plp:*{$product->getName()}*");
}

Result: PLP cache has high hit rate (60–80%) because minimal data changes frequently.

H. Real-World Scenario (Benchmarked Against Live Magento)

Results from actual DDEV Magento 2.4.8-p1 with OpenSearch:

PLP Query Comparison (24 products):

FULL _source (all fields):
├─ Payload: 87KB
├─ Query time: 838ms
├─ Per product: 3,727 bytes
└─ Bottleneck: Network transfer

MINIMAL _source (3 fields):
├─ Payload: 4KB
├─ Query time: 809ms
├─ Per product: 165 bytes
└─ Improvement: ~3% query time, 95% payload

Real Network Impact @ 10Mbps (typical mobile):
├─ Full response: 70ms network latency
├─ Minimal response: 3ms network latency
└─ Savings: ~67ms per query

Per 1000 queries (typical daily PLP traffic):
├─ Time saved: 67 seconds = 1+ minute/day
├─ Bandwidth saved: ~83KB per 24 products
├─ At 1000 queries/day: ~3.5MB bandwidth saved
└─ Real-world: Faster browser rendering + lower costs

Key Finding: Query engine time is negligible (OpenSearch is fast). Real optimization value is payload reduction (95%) → network latency savings → browser rendering.

Typical User Experience:

Slow mobile (4G, 10Mbps):
- Before: 87KB payload + 70ms network = 150ms+ perceived latency
- After: 4KB payload + 3ms network = 40ms+ perceived latency
- Difference: User sees results 3-4x faster on slow networks

Fast broadband (100Mbps):
- Before: 87KB payload + 7ms network = ~30ms
- After: 4KB payload + <1ms network = ~5ms
- Difference: Minimal (both are fast)

Real impact: Mobile and developing regions benefit most

tradeoff: index-time pre-computation versus template rendering {#section-viii}

Updated: Article initially underestimated template rendering overhead. While PHP number_format() is negligible (0.0003ms), the actual Magento price rendering system using PriceBox + RendererPool + template resolution has real cost. However, this is STILL dominated by network payload.

A. Real Magento Price Rendering Architecture

Magento doesn’t just format prices with number_format(). The actual flow:

// Magento\Catalog\Pricing\Render\PriceBox (template: default.phtml)
// Uses RendererPool for template resolution
class PriceBox extends \Magento\Framework\Pricing\Render\PriceBox {
  public function toHtml() {
    // 1. Template lookup/resolution (~0.5-1.5ms)
    //    ├─ RendererPool searches for template
    //    ├─ Cache lookup
    //    └─ File load if not cached
    
    // 2. Template execution (~0.5-2.0ms)
    //    ├─ PHP template execution (price.phtml)
    //    ├─ number_format() for each price variant (0.0003ms each)
    //    ├─ HTML/string building
    //    └─ Child template rendering (amount.phtml)
    
    // Total: 1-3.5ms per product
  }
}

B. Real Rendering Costs (Measured from Magento Source)

For different product types (from RendererPool configuration):

Product TypeTemplates RenderedCost per ProductNotes
Simple2 (default.phtml + amount.phtml)1-2msBase case
Configurable3 (above + configured_price.phtml)1.5-2.5msExtra price variant
Bundle3-4 (above + bundle variants)2-3msComplex pricing
Grouped2-3 (product/group rendering)1.5-2.5msMultiple products

Breakdown of 2ms rendering:

├─ RendererPool template lookup: 0.5-1.0ms
│  ├─ Cache hit (normal): ~0.1ms
│  └─ Cache miss (first load): ~5-10ms (amortized over many renders)
├─ Template execution (price.phtml): 0.5-1.0ms
│  ├─ number_format() calls: 0.0003ms × 5 prices = 0.0015ms
│  ├─ Price calculation/retrieval: 0.2-0.5ms
│  └─ HTML building: 0.3-0.5ms
├─ Child template (amount.phtml): 0.3-0.7ms
│  ├─ Currency formatting
│  └─ HTML wrapping
└─ Renderer cache + minor overhead: 0.2-0.4ms

C. Real Magento Complexity: 25-30 Price Fields Per Product

The problem isn’t formatting cost, it’s the DATA SIZE:

Magento stores 25-30 price-related fields per product:
├─ Base price: 1 field
├─ Tier prices (qty 1-5): 5 entries
├─ Special prices (by customer group): 5 variations
├─ Regular + final price: 10 fields (2 × 5 groups)
├─ Rendered HTML prices: 5 variations
├─ Additional prices (cost, MSRP): 3-4 fields
└─ Total: ~25-30 fields @ 40-80 bytes each = 1,200 bytes per product

Rendering this in PLP:
└─ 48 products × 1,200 bytes = 57,600 bytes
└─ Rendering ALL prices (template): 48 × 2ms = 96ms

BUT: If using lazy-load (Section VII), you only fetch 5-10 price fields:

└─ 48 products × 250 bytes = 12,000 bytes (80% reduction)
└─ Network transfer @ 10Mbps: 3ms (vs 70ms full)
└─ Rendering only essential prices: 48 × 0.5ms = 24ms (only once, cached)

D. When Index-Time Pre-Computation Makes Sense

NOT for:

  • ✗ Simple price formatting (0.0003ms per price – negligible)
  • ✗ Template rendering optimization (2ms per product – already fast)
  • ✗ Pre-rendering HTML (caching is better)

YES for:

  • ✓ Payload reduction (store only PLP-essential fields: 95% smaller)
  • ✓ Facet aggregations (pre-compute counts in cron, cache 1h)
  • ✓ Complex attribute transformations (normalizations, concatenations)
  • ✓ Dimensional rankings (pre-scored search results)

E. Payload Optimization (The REAL Value)

Summary: What Actually Costs Time in PLP Rendering

OperationCost per 48-Product PLPOptimization Strategy
Template rendering (all prices)96ms (2ms × 48)Cache templates (built-in)
number_format() per price0.014msSKIP (negligible)
Network transfer (full 87KB)70ms @ 10MbpsLazy-load (95% reduction) ← REAL WIN
Network transfer (minimal 4KB)3ms @ 10MbpsAlready optimized

What should be pre-computed in OpenSearch:

{
  "price_for_plp": "$39.99",                    // Payload reduction (not CPU)
  "facet_color_count": 5,                       // Expensive aggregation
  "search_score": 0.95,                         // Complex ranking
  "availability_for_region_us": "in_stock"      // Regional database logic
}

Why:

  • ✓ Reduces payload (87KB → 4KB = 95% reduction) — Saves 67ms network
  • ✓ Pre-computed facets cached in cron — Saves 500ms+ per query
  • ✓ Pre-ranked results — Saves 100-500ms sorting
  • ✗ NOT for simple formatting (0.0003ms/price — negligible vs 2ms rendering)

C. What to Pre-Compute (Real Use Cases)

High-value pre-computations:

1. Facet Counts (expensive aggregation)

  • Why: Aggregations are O(n) on full result set
  • Benefit: Instant facet display

2. Search Ranking Scores (complex calculation)

  • Why: Ranking algorithms are CPU-intensive
  • Benefit: Faster sorting by relevance

3. Regional Availability (multi-region logic)

  • Why: Database lookups or complex queries
  • Benefit: Fast region-specific display

Low-value pre-computations:

  • ✗ Simple price formatting (0.0003ms/price – negligible)
  • ✗ String concatenation (already instant)
  • ✗ Simple unit conversion (0.0001ms – negligible)

D. Implementation Pattern (Generic)

For any expensive pre-computed field:

// 1. Calculate at index time
$product->setData('facet_color_counts', [
  'red' => 45, 'blue' => 32
]);

// 2. Store as JSON in OpenSearch
$opensearch->index([
  'body' => [
    'facet_color_counts' => $product->getData('facet_color_counts')
  ]
]);

// 3. Retrieve without recalculation
$query = [
  '_source' => ['facet_color_counts'],  // No aggregation needed
  'size' => 100
];

E. Tradeoff: Index Size vs Query Speed

Cost of pre-computing:
├─ Index size: +5-10% (store pre-computed data)
├─ Indexing time: +10-20% (calculate during index)
└─ Invalidation: Complex when data changes

Benefit:
├─ Query speed: 2-10x improvement
├─ CPU usage: 30-50% reduction
└─ Real-time display: Instant facets/rankings

ROI Decision:

Good when:
├─ Field is read 100s times/day
├─ Calculation is expensive (aggregation, ranking)
└─ Data changes infrequently

Not worth it when:
├─ Field is read <10 times/day
├─ Calculation is instant (<1ms)
└─ Data changes frequently

verification: benchmark it yourself (show your work) {#section-benchmarking}

A. Benchmark Script

#!/bin/bash
# benchmark-es.sh: Measure query latency before/after optimization

echo "Elasticsearch Performance Benchmark"
echo "===================================="

# 1. Setup: Create test index
curl -X POST http://localhost:9200/benchmark_test -H "Content-Type: application/json" -d '{
  "settings": {
    "number_of_shards": 2,
    "number_of_replicas": 0
  }
}'

# 2. Load test data (10k products)
for i in {1..10000}; do
  curl -X POST http://localhost:9200/benchmark_test/_doc \
    -H "Content-Type: application/json" \
    -d "{
      \"name\": \"Product $i\",
      \"sku\": \"SKU-$(printf '%05d' $i)\",
      \"price\": $((RANDOM % 1000)),
      \"color\": [\"red\", \"blue\", \"green\"][$((RANDOM % 3))],
      \"status\": \"enabled\"
    }" > /dev/null
done

echo "Loaded 10k test products"

# 3. Benchmark: 100 queries, measure average
echo ""
echo "Running 100 search queries..."

total_time=0
for i in {1..100}; do
  query_time=$(curl -s -w "%{time_total}" -X GET "http://localhost:9200/benchmark_test/_search" \
    -H "Content-Type: application/json" \
    -d '{
      "query": { "match": { "name": "Product" } },
      "aggs": {
        "colors": { "terms": { "field": "color", "size": 20 } }
      }
    }' -o /dev/null | tail -1)
  
  total_time=$(echo "$total_time + $query_time" | bc)
done

avg_time=$(echo "scale=3; $total_time / 100 * 1000" | bc)
echo "Average query latency: ${avg_time}ms"

# 4. Cleanup
curl -X DELETE http://localhost:9200/benchmark_test

B. Before & After Results

Before optimization:

Catalog: 100k products
Shards: 5 (default)
Analyzer: full stemming

Query 1-10: 850ms
Query 11-50: 750ms (cache warming)
Query 51-100: 720ms (cache hit)
Average: 750ms ❌

After optimization:

Catalog: 100k products
Shards: 2
Analyzer: no stemming
Facets cached

Query 1-10: 250ms
Query 11-50: 120ms (cache warming)
Query 51-100: 50ms (cache hit + application cache)
Average: 140ms ✅
Improvement: 5.4x

C. Profiling Queries (Find Bottlenecks)

{
  "query": { ... },
  "profile": true  // Enable timing breakdown
}

Output (shows where time is spent):

{
  "profile": {
    "shards": [
      {
        "timing": {
          "type": "BooleanQuery",
          "description": "...",
          "time_in_nanos": 50000000,  // 50ms
          "breakdown": {
            "score": 30000000,  // 30ms on scoring
            "match": 20000000   // 20ms on matching
          }
        }
      }
    ]
  }
}

Interpretation:

  • If scoring > matching: optimize relevance algorithm
  • If matching > scoring: optimize query structure (fewer terms, better filters)

Common pitfalls

A. Reindexing is Slow

Reindexing 1M products: ~30 minutes (don’t do during peak hours)

Safe reindexing (no downtime):

# 1. Create new index with new mapping
PUT /catalog_product_v2

# 2. Reindex old → new
POST /_reindex
{
  "source": { "index": "catalog_product_v1" },
  "dest": { "index": "catalog_product_v2" }
}
# Takes 30 min

# 3. Switch alias (atomic)
POST /_aliases
{
  "actions": [
    { "remove": { "index": "catalog_product_v1", "alias": "catalog_product" } },
    { "add": { "index": "catalog_product_v2", "alias": "catalog_product" } }
  ]
}

# 4. Delete old index
DELETE /catalog_product_v1

Benefit: Queries use alias; no downtime during reindex

B. Analyzer Changes Require Reindex

❌ Wrong: Change analyzer, expect old documents to use new analyzer

  • Old documents: analyzed with OLD analyzer (have stems)
  • New documents: analyzed with NEW analyzer (no stems)
  • Inconsistent results

✅ Right: Reindex when changing analyzer

  • Guarantees all documents use new analyzer
  • Consistent search results

C. Disk Space Fills Up

Elasticsearch uses lots of disk (1.5–2x data size):

Monitor disk:

# Alert if >80% full
disk_pct=$(df /var/lib/elasticsearch | tail -1 | awk '{print $5}' | sed 's/%//')
if [ $disk_pct -gt 80 ]; then
  send_alert "Elasticsearch disk 80%+ full"
fi

Free space:

# Delete old indices
curl -X DELETE http://localhost:9200/catalog_product_v1

# Shrink index
POST /catalog_product_v2/_shrink/catalog_product_v2_shrunk

Related reading

  • Magento OpenSearch Tuning: Shards, Mapping and JVM — the other half: how the cluster itself is configured, and the settings underneath everything here.
  • Magento 2 Indexers: mview, Schedule, and Stuck — the pipeline that fills the index this article queries.
  • Magento TTFB: Varnish, Redis and the Backend Cost — search is one component of category-page response time.
  • Core Web Vitals Hardening — PLP payload size is an LCP problem before it is a search problem.
  • Magento 2 GraphQL Resolvers: N+1 and Cache Tags — the other place a product list turns into hundreds of queries.

Sources & References

Disclaimer

Numbers here were measured on Magento 2.4.8-p1 with OpenSearch 2.11 in a DDEV environment, whose container latency floor is why the sub-100 ms optimizations are labelled expected rather than quantified. Re-run the script in the verification section against your own staging cluster before committing engineering time to any single change.

Version note: on Magento 2.4.8 and later, OpenSearch is the only supported catalog search engine — Adobe removed the Elasticsearch options entirely. Everything here applies unchanged to either engine on 2.4.7 and earlier.

Related Topics

← Previous

Written in collaboration with AI (Claude, by Anthropic). Ideas, verification, and accountability are mine; research and drafting are AI-assisted. Full disclosure → · Found an error? Tell me.