DEV Community

Magevanta
Magevanta

Posted on • Originally published at magevanta.com

Magento 2 Custom Product Options Performance: Optimization at Scale

Custom product options are one of those features every Magento store uses, but few optimize. They're deceptively simple — a dropdown for gift wrapping, a text field for engraving, a checkbox for insurance. What could possibly go wrong?

Plenty, as it turns out. The underlying database structure for custom options involves up to five separate tables and generates multiple JOINs on every product page load, every cart addition, and every checkout step. For stores with complex options (think 50+ options on configurable products, or 20+ select options with hundreds of values), the performance impact can be dramatic.

In this post, I'll show you exactly how custom options work under the hood, where the performance bottlenecks live, and how to optimize them at scale.

How Custom Options Are Stored

Custom options in Magento 2 use a normalized database structure across five tables:

Table Purpose
catalog_product_option Option definitions (title, type, sort order)
catalog_product_option_type_value Selectable values for dropdown/multiple/radio/checkbox options
catalog_product_option_price Price adjustments per option or value
catalog_product_option_title Store-specific titles
catalog_product_option_type_price Price adjustments per value per store

That's already five JOINs or five separate queries just to load options for a single product. When Magento loads a product page, it doesn't just load the product — it loads the entire option tree, including prices for every store view.

The Default Loading Pattern

Here's what happens when you visit a simple product page with custom options:

  1. Magento\Catalog\Block\Product\View\Options calls getProduct()->getOptions()
  2. This triggers Magento\Catalog\Model\Product\Option::getCollection() which loads all options for the product
  3. For each option, the type values, prices, and titles are loaded — either eagerly via JOIN or lazily per option
  4. The price calculation for each option value runs through the Catalog\Model\Product\Option\Value model
  5. All of this happens on every uncached product page view

The problem compounds when you have 20+ options with 50+ values each — we're talking about hundreds of SQL queries just to render the options block.

The Real Bottlenecks

1. Option Collection Loading

The Magento\Catalog\Model\ResourceModel\Product\Option\Collection performs a LEFT JOIN to catalog_product_option_title and catalog_product_option_price for every option. If you have options with different price types (fixed vs. percentage), the query gets more complex.

SELECT `main_table`.*, `option_title`.`title` AS `store_title`,
       `option_price`.`price` AS `store_price`,
       `option_price`.`price_type` AS `store_price_type`
FROM `catalog_product_option` AS `main_table`
LEFT JOIN `catalog_product_option_title` AS `option_title`
    ON main_table.option_id = option_title.option_id
    AND option_title.store_id = 0
LEFT JOIN `catalog_product_option_price` AS `option_price`
    ON main_table.option_id = option_price.option_id
    AND option_price.store_id = 0
WHERE (main_table.product_id IN (...))
Enter fullscreen mode Exit fullscreen mode

With 100 products loaded on a category page or during a price rule calculation, those queries multiply.

2. Value Price Calculation

For select-type options (dropdown, multiple, radio, checkbox), Magento calculates the price for each option value individually. The getValues() method on an option object triggers separate queries to load titles, prices, and SKU data.

The price calculation itself fires plugin chains: Catalog\Model\Product\Option\Value::getPrice() can be intercepted by catalog rules, customer group pricing, and third-party modules. Each plugin in the chain adds overhead.

3. Cart & Quote Operations

When a customer adds a product with custom options to the cart, Magento clones the option data into the quote system. The Magento\Quote\Model\Quote\Item\Option table receives the selected option values, and every subsequent cart/checkout operation loads them again.

During checkout, each quote item's custom options are loaded and re-calculated — including price adjustments, SKU changes, and weight modifications. With 10+ items each having 3–5 options, that's 30–50 option load operations per checkout request.

4. SKU Management

Custom options can change a product's SKU (e.g., a base product SKU with an option-specific suffix). The SKU lookup during cart operations and order placement queries the option value tables to reconstruct the full SKU, adding another query layer.

Optimization Strategies

1. Index the Right Columns

The most basic optimization with the biggest ROI: ensure your option tables have proper indexes.

ALTER TABLE catalog_product_option ADD INDEX IDX_PRODUCT_ID (product_id);
ALTER TABLE catalog_product_option_type_value ADD INDEX IDX_OPTION_ID (option_id);
ALTER TABLE catalog_product_option_price ADD INDEX IDX_OPTION_ID_STORE (option_id, store_id);
ALTER TABLE catalog_product_option_type_price ADD INDEX IDX_VALUE_ID_STORE (value_id, store_id);
Enter fullscreen mode Exit fullscreen mode

Magento's default installation creates indexes on the primary keys but skips composite indexes on the most common query patterns. Adding these indexes can cut query time by 80% on large option sets.

2. Cache Option Data with a Custom Module

The option structure for a product rarely changes — it's updated only when a merchant edits the product. You can cache the parsed option tree and invalidate it on product save.

<?php
// app/code/Vendor/OptionCache/Plugin/CacheProductOptions.php

class CacheProductOptions
{
    private Cache $cache;
    private Serializer $serializer;

    public function afterGetOptions(
        \Magento\Catalog\Model\Product $product,
        $options
    ) {
        if (!$product->getId()) {
            return $options;
        }

        $cacheKey = 'product_options_' . $product->getId();

        if ($cached = $this->cache->load($cacheKey)) {
            return $this->serializer->unserialize($cached);
        }

        $this->cache->save(
            $this->serializer->serialize($options),
            $cacheKey,
            [\Magento\Catalog\Model\Product::CACHE_TAG . '_OPTIONS'],
            86400
        );

        return $options;
    }
}
Enter fullscreen mode Exit fullscreen mode

Invalidate the cache on product save:

public function afterSave(\Magento\Catalog\Model\Product $product, $result)
{
    $this->cache->clean([\Magento\Catalog\Model\Product::CACHE_TAG . '_OPTIONS']);
    return $result;
}
Enter fullscreen mode Exit fullscreen mode

3. Optimize Plugin Chains on Option Models

The Magento\Catalog\Model\Product\Option\Value::getPrice() method is a hotspot. Profile it with Blackfire or Xdebug to see which plugins are running. Common culprits:

  • Catalog rule price adjustments — disable or optimize if not used with custom options
  • Customer group pricing — can add 50ms+ per option value
  • Third-party extension plugins — audit each one
<!-- etc/di.xml -->
<type name="Magento\Catalog\Model\Product\Option\Value">
    <plugin name="your_custom_options_plugin" sortOrder="1" disabled="false"/>
</type>
Enter fullscreen mode Exit fullscreen mode

4. Batch Load Options Instead of Lazy Loading

Override the default option loading behavior to eager-load all option data in a single query:

// Override getOptions() in a custom product repository plugin
public function afterGetById(
    \Magento\Catalog\Api\ProductRepositoryInterface $subject,
    \Magento\Catalog\Api\Data\ProductInterface $product
) {
    if ($product->getId()) {
        $product->setOptions(
            $this->optionRepository->getProductOptions($product, true)
        );
    }
    return $product;
}
Enter fullscreen mode Exit fullscreen mode

The true parameter forces the collection to load all option titles, prices, and values in one go rather than lazy-loading per option.

5. Consider Alternatives at Scale

For stores with extreme option requirements (100+ options per product, or 1000+ option values), consider whether custom options are the right tool:

  • Configurable products — better for visual selections (size, color) with native inventory tracking
  • Bundle products — better for product kits where each component needs stock tracking
  • Product variants — if every combination behaves as its own product, you're better off with configurable + simple products
  • Custom EAV attributes — for non-priced add-ons, use Yes/No attributes instead of checkbox options

A good rule of thumb: if the option changes the price, use custom options. If it only changes the product selection, use attributes and configurable products instead.

6. Minimize Checkout Reprocessing

During checkout, Magento recalculates option prices for every quote item. You can optimize this by:

  • Adding a custom price cache for quote item options
  • Using beforeQuoteCollectTotals observer to skip option recalculation when nothing has changed
  • Implementing Magento\Quote\Model\Quote\Item\Option cleanup to remove orphaned option records

Monitoring Custom Option Performance

Add these queries to your monitoring toolkit:

-- Check which products have the most options
SELECT product_id, COUNT(*) AS option_count
FROM catalog_product_option
GROUP BY product_id
ORDER BY option_count DESC
LIMIT 20;

-- Check for options with excessive values
SELECT o.option_id, o.title, COUNT(v.option_type_id) AS value_count
FROM catalog_product_option o
JOIN catalog_product_option_type_value v ON o.option_id = v.option_id
GROUP BY o.option_id
ORDER BY value_count DESC
LIMIT 20;

-- Slow queries to watch for in MySQL
SELECT * FROM performance_schema.events_statements_summary_by_digest
WHERE digest_text LIKE '%catalog_product_option%'
ORDER BY avg_timer_wait DESC;
Enter fullscreen mode Exit fullscreen mode

Also add these checks to your Blackfire/Xdebug profiling runs when testing product pages and checkout:

  • Option collection load time — should be under 50ms total
  • Single option value price calculation — under 5ms per value
  • Quote item option load during checkout — under 20ms per item

Real-World Impact

I worked with a Magento 2 store selling personalized gifts with 30+ custom options (engraving text, font choice, wrapping type, ribbon color, gift message, delivery date — each with 10–50 values). Their product page was taking 4.2 seconds to render, with 60% of that time in custom option loading.

After implementing the optimizations above:

  1. Proper indexes reduced option queries from 45ms to 8ms
  2. Option cache eliminated repeated loading on subsequent page views
  3. Plugin audit removed 3 unnecessary price calculation plugins
  4. Eager loading reduced query count from 60+ to 6

Result: product page render time dropped from 4.2s to 1.1s, and checkout processing time dropped by 40%.

Summary

Custom product options are a hidden performance tax that grows with your catalog. The fix is rarely about removing options — it's about loading them smarter:

  1. Index your option tables — the quickest win
  2. Cache the parsed option tree — eliminates repeated queries
  3. Audit your plugin chains — option price calculations are plugin-heavy
  4. Eager-load instead of lazy-load — one query beats 60 queries
  5. Scale with the right product type — configurable or bundle may be better at extreme scale

The leanest custom option is the one you don't have to query twice.

Top comments (0)