The Battle for Milliseconds: How to Optimize Heavy Filtering in catalog.section
Rus
Eng
Борьба за миллисекунды: как оптимизировать тяжелую фильтрацию в catalog.section
I solve complex technical challenges for Bitrix and 1C—ranging from auditing existing code to website development and configuring complex integrations—with a guarantee of results.
Recommended

Website hosting

If you are choosing hosting for your project, I recommend Timeweb. Modern infrastructure, a wide range of plans from starter options to high-load VPS, and responsive tech support that actually helps instead of sending template replies. I host some of my own projects there and recommend it to my clients.

Go to site Affiliate link. The price remains the same for you.

All English-language materials have been translated fully automatically using the Google service

Why the catalog.section Filter Devours Your Server

A catalog with 500,000 products, 40 properties, 15 active filters at once. The page takes 8 seconds to render. The server sits at 100% load. MySQL scans millions of rows across property tables because every filter property is a separate JOIN, and without indexes the database reads the entire table. The default arrFilter that worked fine on 10,000 items turns into a slow query generator once the catalog hits a million.

This doesn't mean catalog.section is bad. It means its default settings are tuned for a mid-sized catalog, not an industrial one. The same goes for catalog.smart.filter: it builds its facet selection on top of the same EAV tables and is often the second — or the main — source of slowness. We'll walk through seven levels of optimization: first find what's actually slow, then rewrite the query through ORM, optimize the smart filter itself, tune caching, prop it up with indexes, and — if none of that is enough — push filtering to an external search engine.

Level 1. Find the Bottleneck, Don't Guess

The first thing a developer does when they see a slow catalog is dive into the code and start "optimizing" blindly. That path leads nowhere. Get facts first.

Bitrix Performance Monitor

The built-in performance monitor is the fastest way to see which queries actually run on the catalog page. Enable it in the main module settings, "Performance Monitor" section. Once enabled, a panel with a query tree appears at the bottom of the page.

What to look at first:

  • The slowest queries. Usually the element selection by arrFilter with a dozen JOINs to property tables.
  • Query count. If a catalog page runs 200+ queries, the problem isn't one slow query — it's that every list item pulls properties separately.
  • Duplicate queries. The same query can run 10–15 times per page. That's a sign the cache isn't working, or is working incorrectly.

The monitor shows the full SQL query. Copy the slowest one and feed it into EXPLAIN ANALYZE.

The Smart Filter: catalog.smart.filter

People talk a lot about catalog.section and almost never about catalog.smart.filter, even though the latter often eats more. The component does two heavy things at once: for each value of each property it counts matching products, and it assembles the list of available values given the already-applied filters.

Inside catalog.smart.filter there are two modes. The first works through the facet index: the component reads the denormalized b_iblock_N_index table and pulls ready-made counters. The second is a direct scan of EAV tables: if the facet index is stale, old, or was never built, the component falls back to a GROUP BY over millions of rows.

What to look at in the performance monitor for this component:

  • Queries to b_iblock_N_index. If they're missing and instead you see heavy GROUP BY queries on b_iblock_element_prop_* — the facet index isn't working.
  • Query count against property tables. The smart filter can fire dozens of separate selections — one per property, plus one per counter.
  • Total component time. The monitor breaks time down per component. If catalog.smart.filter eats 60–70% of page generation time, optimize it first.

EXPLAIN ANALYZE Instead of the Old EXPLAIN

Since MySQL 8.0.18, EXPLAIN ANALYZE gives real measurements, not just a plan: how many rows actually passed through each stage, how much time each took. For optimizing complex queries this is more accurate than the old EXPLAIN, whose estimates often mislead.

EXPLAIN ANALYZE
SELECT BE.ID
FROM b_iblock_element BE
WHERE BE.IBLOCK_ID = 5
  AND EXISTS (
      SELECT 1 FROM b_iblock_element_prop_m5 P
      WHERE P.IBLOCK_ELEMENT_ID = BE.ID
        AND P.IBLOCK_PROPERTY_ID = 1075
        AND P.VALUE_ENUM IN (101, 102, 103)
  )
LIMIT 20;

In the output, look at the actual time and rows columns — where the time is really spent and where the optimizer's estimate was off. If actual rows differs from the estimate by an order of magnitude, the statistics are stale or the index isn't being used.

pt-query-digest: How to Read the Report

slow query log is enabled in my.cnf: slow_query_log = 1, long_query_time = 2. After a day you get statistics on real slow queries. Then pt-query-digest from Percona Toolkit groups them by frequency and total time.

pt-query-digest /var/log/mysql/slow.log

The key block is the top "Profile" sorted by total time, not by individual queries. Example output:

# Profile
# Rank Query ID           Response time Calls  R/Call  V/M   Item
# ==== ================== ============= ====== ======= ===== ======
#    1 0x8C4F2B7E1A9D3C05 312.4001 68.2%  84213  0.0037  0.02 SELECT b_iblock_element_prop_m5
#    2 0x2A1D5C8F9B7E4A03  78.9002 17.2%  42106  0.0019  0.01 SELECT b_iblock_element
#    3 0x1F3E8B2C4A5D9E07  45.2001  9.9%   9821  0.0046  0.02 SELECT b_iblock_section

The first row says: 84,213 calls to a single query ate 68% of all time. You don't need to optimize ten different queries, you need to optimize one. That's the practical takeaway: find the query with the largest percentage and the lowest R/Call — that's where the bottleneck lives.

Measurements: What and How to Count

Any claim of "it got faster" without numbers is self-deception. You need at least three metrics before and after: page generation time (TTFB), total query count, and total SQL execution time on the page. The Bitrix performance monitor gives all three.

As for the difference between the old API and ORM, it depends heavily on:

  • the number of active filters in arrFilter (each filter is a JOIN);
  • the size of property tables (the bigger the catalog, the wider the gap);
  • the presence of indexes;
  • the MySQL version and innodb_buffer_pool_size settings.

On a typical 100,000-product catalog with 6–8 active filters where properties live in EAV, the advantage of ORM with a subquery is usually 5x to 30x. On a 10,000-product catalog without multi-value properties the difference might be just 1.5–2x and not worth the maintenance complexity.

The only honest way to know what's happening on your project is to measure it yourself.

Level 2. Filtering by Properties via D7 ORM

The classic CIBlockElement::GetList with arrFilter works like this: you pass an array of conditions, and Bitrix builds SQL with a dozen JOINs to property tables. On a small catalog that's fine. On a large one — each JOIN adds rows, and the query balloons to hundreds of thousands of rows in the intermediate result.

D7 ORM gives you tools the old GetList doesn't have: ExpressionField, subqueries, Runtime fields. They let you push computation to the SQL side.

The Main Difficulty: Split Property Storage

Bitrix has two modes for storing infoblock properties. With the common scheme, all properties live in the b_iblock_element_property table, bound to the \Bitrix\Iblock\ElementPropertyTable entity. With split storage (infoblocks 2.0, enabled by default on modern projects), simple properties go to b_iblock_element_prop_s{ID}, multi-value ones to b_iblock_element_prop_m{ID}, and ElementPropertyTable simply doesn't see them.

This means the ReferenceField examples pointing at ElementPropertyTable that are floating around in articles only work on legacy projects with a common table. Below is an approach that works in both modes.

Filtering by Property via an EXISTS Subquery

Instead of ReferenceField, register an ExpressionField with a correlated EXISTS subquery. MySQL runs it via the (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE_ENUM) index — in that order, because the element condition comes first in the subquery. Important: values must be escaped through SqlHelper::forSql().

use Bitrix\Main\Loader;
use Bitrix\Main\Application;
use Bitrix\Main\Entity\Query;
use Bitrix\Main\Entity\ExpressionField;

Loader::includeModule('iblock');

$iblockId = 5;
$propertyId = 1075;
$tableName = 'b_iblock_element_prop_m' . $iblockId;

// Escaping values — mandatory
$helper = Application::getConnection()->getSqlHelper();
$values = [101, 102, 103];

$query = new Query(\Bitrix\Iblock\ElementTable::getEntity());

$query->registerRuntimeField(
    'IN_BRAND',
    new ExpressionField(
        'IN_BRAND',
        'EXISTS (SELECT 1 FROM ' . $tableName
            . ' WHERE IBLOCK_ELEMENT_ID = %s'
            . ' AND IBLOCK_PROPERTY_ID = ' . (int)$propertyId
            . ' AND VALUE_ENUM IN (' . implode(',', array_map('intval', $values)) . '))',
        ['ID']
    )
);

$query->addSelect('ID');
$query->addSelect('NAME');

$query->setFilter([
    '=IBLOCK_ID' => $iblockId,
    '=ACTIVE' => 'Y',
    '=IN_BRAND' => 1,
]);

$result = $query->exec();

while ($row = $result->fetch()) {
    // $row['ID'], $row['NAME']
}

Why EXISTS and not CASE WHEN ID IN (...): wrapping in CASE stops the MySQL optimizer from caching the subquery. EXISTS with a correlated condition on an indexed column runs in logarithmic time per row.

A Universal Method for Any Property Type

The property type is determined once by symbolic code; the table name and column are picked automatically. For string values, escaping through forSql() is mandatory.

/**
 * Builds an EXISTS subquery for filtering by an infoblock property.
 *
 * @param int    $iblockId     Infoblock ID
 * @param string $propertyCode Symbolic property code
 * @param mixed  $value        Value or array of values
 * @return string SQL fragment EXISTS (...)
 */
protected function buildPropertyExists(int $iblockId, string $propertyCode, $value): string
{
    $property = $this->getPropertyInfo($iblockId, $propertyCode);

    if (!$property) {
        throw new \Bitrix\Main\ArgumentException('Property not found: ' . $propertyCode);
    }

    $propertyId = (int)$property['ID'];
    $tableName = $property['MULTIPLE'] === 'Y'
        ? 'b_iblock_element_prop_m' . $iblockId
        : 'b_iblock_element_prop_s' . $iblockId;

    $helper = \Bitrix\Main\Application::getConnection()->getSqlHelper();

    if ($property['PROPERTY_TYPE'] === 'L') {
        $values = array_map('intval', (array)$value);
        $condition = 'VALUE_ENUM IN (' . implode(',', $values) . ')';
    } elseif ($property['PROPERTY_TYPE'] === 'N') {
        $range = array_map('floatval', (array)$value);
        $condition = 'VALUE_NUM BETWEEN ' . $range[0] . ' AND ' . $range[1];
    } else {
        $escaped = $helper->forSql((string)$value);
        $condition = "VALUE LIKE '" . $escaped . "'";
    }

    return 'EXISTS (SELECT 1 FROM ' . $tableName
        . ' WHERE IBLOCK_ELEMENT_ID = %s'
        . ' AND IBLOCK_PROPERTY_ID = ' . $propertyId
        . ' AND ' . $condition . ')';
}

How to Apply ORM Inside catalog.section

The standard catalog.section is tightly bound to CIBlockElement::GetList() and the arrFilter array. Below are two honest paths.

Approach 1. Your Own Selection Component

/local/components/custom/catalog.section.orm/
├── class.php
├── .description.php
└── templates/
    └── .default/
        └── template.php

The component class. Note the key points: the filter comes from $GLOBALS, sections are gathered via a subquery to b_iblock_section_element, the detail URL uses the infoblock template, and both the property filter and the section filter use registerRuntimeField() with ExpressionField — the old ['@ID' => new SqlExpression] syntax is deprecated.

<?php

use Bitrix\Main\Loader;
use Bitrix\Main\Application;
use Bitrix\Main\Entity\Query;
use Bitrix\Main\Entity\ExpressionField;
use Bitrix\Main\UI\PageNavigation;
use Bitrix\Main\DB\SqlExpression;

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

Loader::includeModule('iblock');
Loader::includeModule('catalog');

class CustomCatalogSectionOrm extends CBitrixComponent
{
    /** @var array Property info cache */
    private static $propertyCache = [];

    /** @var string Cached detail page URL template */
    private $detailUrlTemplate = '';

    /** @var array Section code cache by ID */
    private static $sectionCodeCache = [];

    /**
     * Builds element selection via D7 ORM.
     *
     * @param array $arFilter Filter in legacy arrFilter format
     * @param array $arSort   Sort parameters
     * @param PageNavigation $nav Navigation object
     * @return array List of elements
     */
    protected function getElementList(array $arFilter, array $arSort, PageNavigation $nav): array
    {
        $query = new Query(\Bitrix\Iblock\ElementTable::getEntity());

        $this->attachPropertyFilters($query, $arFilter);
        $this->attachSectionFilter($query, $arFilter);

        $query->setSelect([
            'ID',
            'NAME',
            'CODE',
            'PREVIEW_TEXT',
            'PREVIEW_PICTURE',
            'DETAIL_PICTURE',
            'IBLOCK_SECTION_ID',
        ]);

        $query->setFilter($this->buildFilter($arFilter));
        $query->setOrder($arSort ?: ['SORT' => 'ASC']);
        $query->setOffset($nav->getOffset());
        $query->setLimit($nav->getLimit());

        $result = $query->exec();

        $items = [];
        while ($row = $result->fetch()) {
            $row['DETAIL_PAGE_URL'] = $this->buildDetailUrl(
                (int)$row['ID'],
                (string)$row['CODE'],
                (int)$row['IBLOCK_SECTION_ID']
            );
            $items[$row['ID']] = $row;
        }

        if ($items) {
            $this->enrichItems($items);
        }

        return $items;
    }

    /**
     * Attaches the section filter via ExpressionField with a subquery
     * to b_iblock_section_element.
     *
     * @param Query $query    ORM query
     * @param array $arFilter Filter in legacy API format
     * @return void
     */
    protected function attachSectionFilter(Query $query, array $arFilter): void
    {
        if (empty($arFilter['SECTION_ID'])) {
            return;
        }

        $sectionIds = $this->collectSectionIds((int)$arFilter['SECTION_ID']);
        if (!$sectionIds) {
            return;
        }

        $idsSql = implode(',', array_map('intval', $sectionIds));

        $query->registerRuntimeField(
            'IN_SECTION',
            new ExpressionField(
                'IN_SECTION',
                'EXISTS (SELECT 1 FROM b_iblock_section_element SE'
                    . ' WHERE SE.IBLOCK_ELEMENT_ID = %s'
                    . ' AND SE.IBLOCK_SECTION_ID IN (' . $idsSql . '))',
                ['ID']
            )
        );
    }

    /**
     * Attaches property filters via ExpressionField with EXISTS.
     *
     * @param Query $query    ORM query
     * @param array $arFilter Filter in legacy API format
     * @return void
     */
    protected function attachPropertyFilters(Query $query, array $arFilter): void
    {
        if (empty($arFilter['PROPERTY_VALUES']) || !is_array($arFilter['PROPERTY_VALUES'])) {
            return;
        }

        foreach ($arFilter['PROPERTY_VALUES'] as $code => $value) {
            $fieldName = 'IN_PROP_' . strtoupper($code);

            $existsSql = $this->buildPropertyExists(
                (int)$this->arParams['IBLOCK_ID'],
                (string)$code,
                $value
            );

            $query->registerRuntimeField(
                $fieldName,
                new ExpressionField($fieldName, $existsSql, ['ID'])
            );
        }
    }

    /**
     * Builds an EXISTS subquery for filtering by an infoblock property.
     *
     * @param int    $iblockId     Infoblock ID
     * @param string $propertyCode Symbolic property code
     * @param mixed  $value        Value or array of values
     * @return string SQL fragment EXISTS (...)
     */
    protected function buildPropertyExists(int $iblockId, string $propertyCode, $value): string
    {
        $property = $this->getPropertyInfo($iblockId, $propertyCode);

        if (!$property) {
            throw new \Bitrix\Main\ArgumentException('Property not found: ' . $propertyCode);
        }

        $propertyId = (int)$property['ID'];
        $tableName = $property['MULTIPLE'] === 'Y'
            ? 'b_iblock_element_prop_m' . $iblockId
            : 'b_iblock_element_prop_s' . $iblockId;

        $helper = Application::getConnection()->getSqlHelper();

        if ($property['PROPERTY_TYPE'] === 'L') {
            $values = array_map('intval', (array)$value);
            $condition = 'VALUE_ENUM IN (' . implode(',', $values) . ')';
        } elseif ($property['PROPERTY_TYPE'] === 'N') {
            $range = array_map('floatval', (array)$value);
            $condition = 'VALUE_NUM BETWEEN ' . $range[0] . ' AND ' . $range[1];
        } else {
            $escaped = $helper->forSql((string)$value);
            $condition = "VALUE LIKE '" . $escaped . "'";
        }

        return 'EXISTS (SELECT 1 FROM ' . $tableName
            . ' WHERE IBLOCK_ELEMENT_ID = %s'
            . ' AND IBLOCK_PROPERTY_ID = ' . $propertyId
            . ' AND ' . $condition . ')';
    }

    /**
     * Returns property info with caching.
     *
     * @param int    $iblockId     Infoblock ID
     * @param string $propertyCode Symbolic code
     * @return array|null
     */
    protected function getPropertyInfo(int $iblockId, string $propertyCode): ?array
    {
        $cacheKey = $iblockId . ':' . $propertyCode;

        if (isset(self::$propertyCache[$cacheKey])) {
            return self::$propertyCache[$cacheKey];
        }

        $row = \Bitrix\Iblock\PropertyTable::getList([
            'filter' => ['=IBLOCK_ID' => $iblockId, '=CODE' => $propertyCode],
            'select' => ['ID', 'PROPERTY_TYPE', 'MULTIPLE'],
            'limit' => 1,
        ])->fetch();

        self::$propertyCache[$cacheKey] = $row ?: null;

        return self::$propertyCache[$cacheKey];
    }

    /**
     * Builds the detail page URL via the infoblock template
     * with SEF placeholders substituted.
     *
     * @param int    $elementId   Element ID
     * @param string $elementCode Symbolic element code
     * @param int    $sectionId   Primary section ID
     * @return string
     */
    protected function buildDetailUrl(int $elementId, string $elementCode, int $sectionId): string
    {
        if ($this->detailUrlTemplate === '') {
            $iblock = \CIBlock::GetArrayByID((int)$this->arParams['IBLOCK_ID']);
            $this->detailUrlTemplate = $iblock['DETAIL_PAGE_URL'] ?? '/catalog/#ELEMENT_CODE#/';
        }

        $sectionCode = $this->getSectionCode($sectionId);

        return str_replace(
            ['#IBLOCK_ID#', '#IBLOCK_CODE#', '#SECTION_ID#', '#SECTION_CODE#', '#ELEMENT_ID#', '#ELEMENT_CODE#', '#SITE_DIR#'],
            [(int)$this->arParams['IBLOCK_ID'], '', $sectionId, $sectionCode, $elementId, $elementCode, SITE_DIR],
            $this->detailUrlTemplate
        );
    }

    /**
     * Returns the section symbolic code with caching.
     *
     * @param int $sectionId Section ID
     * @return string
     */
    protected function getSectionCode(int $sectionId): string
    {
        if ($sectionId <= 0) {
            return '';
        }

        if (isset(self::$sectionCodeCache[$sectionId])) {
            return self::$sectionCodeCache[$sectionId];
        }

        $row = \Bitrix\Iblock\SectionTable::getList([
            'filter' => ['=ID' => $sectionId, '=IBLOCK_ID' => $this->arParams['IBLOCK_ID']],
            'select' => ['CODE'],
            'limit' => 1,
        ])->fetch();

        self::$sectionCodeCache[$sectionId] = $row['CODE'] ?? '';

        return self::$sectionCodeCache[$sectionId];
    }

    /**
     * Converts arrFilter into D7 ORM conditions. The section subquery
     * is registered in attachSectionFilter(), here we only reference it.
     *
     * @param array $arFilter Filter in legacy API format
     * @return array
     */
    protected function buildFilter(array $arFilter): array
    {
        $filter = [
            '=IBLOCK_ID' => (int)$this->arParams['IBLOCK_ID'],
            '=ACTIVE' => 'Y',
        ];

        if (!empty($arFilter['SECTION_ID'])) {
            $filter['=IN_SECTION'] = 1;
        }

        if (!empty($arFilter['PROPERTY_VALUES']) && is_array($arFilter['PROPERTY_VALUES'])) {
            foreach ($arFilter['PROPERTY_VALUES'] as $code => $value) {
                $filter['=IN_PROP_' . strtoupper($code)] = 1;
            }
        }

        return $filter;
    }

    /**
     * Collects the section ID and all nested subsections in one query
     * via Nested Sets.
     *
     * @param int $sectionId Root section ID
     * @return array
     */
    protected function collectSectionIds(int $sectionId): array
    {
        $rootSection = \CIBlockSection::GetList(
            [],
            ['IBLOCK_ID' => $this->arParams['IBLOCK_ID'], 'ID' => $sectionId],
            false,
            ['ID', 'LEFT_MARGIN', 'RIGHT_MARGIN']
        )->Fetch();

        if (!$rootSection) {
            return [$sectionId];
        }

        $leftMargin = (int)$rootSection['LEFT_MARGIN'];
        $rightMargin = (int)$rootSection['RIGHT_MARGIN'];

        $ids = [$sectionId];

        $rs = \CIBlockSection::GetList(
            ['LEFT_MARGIN' => 'ASC'],
            [
                'IBLOCK_ID' => $this->arParams['IBLOCK_ID'],
                '>LEFT_MARGIN' => $leftMargin,
                '<RIGHT_MARGIN' => $rightMargin,
            ],
            false,
            ['ID']
        );

        while ($row = $rs->Fetch()) {
            $ids[] = (int)$row['ID'];
        }

        return $ids;
    }

    /**
     * Enriches already-selected items with properties and prices.
     *
     * @param array $items Items (by reference)
     * @return void
     */
    protected function enrichItems(array &$items): void
    {
        $ids = array_keys($items);

        $rsElements = \CIBlockElement::GetList(
            [],
            ['IBLOCK_ID' => $this->arParams['IBLOCK_ID'], 'ID' => $ids],
            false,
            false,
            ['ID', 'PROPERTY_*']
        );
        while ($ob = $rsElements->GetNextElement()) {
            $fields = $ob->GetFields();
            $items[$fields['ID']]['PROPERTIES'] = $ob->GetProperties();
        }

        $priceRes = \Bitrix\Catalog\PriceTable::getList([
            'filter' => ['@PRODUCT_ID' => $ids],
            'select' => ['PRODUCT_ID', 'PRICE', 'CURRENCY', 'CATALOG_GROUP_ID'],
        ]);
        while ($price = $priceRes->fetch()) {
            $items[$price['PRODUCT_ID']]['PRICES'][$price['CATALOG_GROUP_ID']] = $price;
        }
    }

    /**
     * Counts the total number of elements under the filter.
     * COUNT instead of COUNT(DISTINCT) — EXISTS subqueries do not duplicate
     * rows in the outer selection, so DISTINCT only slows the query.
     *
     * @param array $arFilter Filter
     * @return int
     */
    protected function countElements(array $arFilter): int
    {
        $query = new Query(\Bitrix\Iblock\ElementTable::getEntity());

        $this->attachPropertyFilters($query, $arFilter);
        $this->attachSectionFilter($query, $arFilter);

        $query->registerRuntimeField('CNT', new ExpressionField('CNT', 'COUNT(%s)', 'ID'));
        $query->setSelect(['CNT']);
        $query->setFilter($this->buildFilter($arFilter));

        $row = $query->exec()->fetch();

        return (int)($row['CNT'] ?? 0);
    }

    public function executeComponent(): void
    {
        $pageSize = (int)($this->arParams['PAGE_ELEMENT_COUNT'] ?: 20);

        $nav = new PageNavigation('catalog-orm-nav');
        $nav->allowAllRecords(false)
            ->setPageSize($pageSize)
            ->initFromUri();

        // The filter is taken the standard Bitrix way — via $GLOBALS
        $filterName = (string)($this->arParams['FILTER_NAME'] ?: '');
        $filter = [];

        if ($filterName !== '' && isset($GLOBALS[$filterName]) && is_array($GLOBALS[$filterName])) {
            $filter = $GLOBALS[$filterName];
        }

        $sort = $this->arParams['SORT'] ?? [];

        $total = $this->countElements($filter);
        $nav->setRecordCount($total);

        $this->arResult['ITEMS'] = $this->getElementList($filter, $sort, $nav);
        $this->arResult['NAV_OBJECT'] = $nav;
        $this->arResult['TOTAL_COUNT'] = $total;
        $this->arResult['FILTER_NAME'] = $filterName;

        $this->includeComponentTemplate();
    }
}

The component template. Every field from the database is output via htmlspecialcharsbx().

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

/** @var array $arResult */
/** @var array $arParams */

$nav = $arResult['NAV_OBJECT'] ?? null;
$items = $arResult['ITEMS'] ?? [];

if (!$items) {
    return;
}
?>

<div class="catalog--list">
    <?php foreach ($items as $item): ?>
        <div class="catalog--item" data-id="<?= (int)$item['ID'] ?>">
            <a class="catalog--link" href="<?= htmlspecialcharsbx($item['DETAIL_PAGE_URL']) ?>">
                <span class="catalog--name"><?= htmlspecialcharsbx($item['NAME']) ?></span>
            </a>
            <?php if (!empty($item['PREVIEW_TEXT'])): ?>
                <div class="catalog--preview"><?= htmlspecialcharsbx($item['PREVIEW_TEXT']) ?></div>
            <?php endif; ?>
        </div>
    <?php endforeach; ?>
</div>

<?php
if ($nav && $nav->getPageCount() > 1) {
    $APPLICATION->IncludeComponent(
        'bitrix:main.pagenavigation',
        '',
        [
            'NAV_OBJECT' => $nav,
            'SEF_MODE' => 'N',
        ],
        false
    );
}

How the Custom catalog.section Connects to the Stock Smart Filter

The key to connecting them is the FILTER_NAME parameter. The stock catalog.smart.filter writes the current filter into $GLOBALS under that name, and the custom catalog.section.orm reads it under the same name.

About FILTER_NAME and collisions. The name arrFilter is standard for Bitrix and may already be taken in the site template. Before integration, check:

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

// Define the filter name once to avoid clashing with other blocks
$filterName = 'catalogArrFilter';

// If the variable is already taken, pick a custom name
if (isset($GLOBALS[$filterName]) && !is_array($GLOBALS[$filterName])) {
    $filterName = 'catalogArrFilter_' . $APPLICATION->GetCurPage();
}

$iblockId = 5;

$APPLICATION->IncludeComponent(
    'bitrix:catalog.smart.filter',
    '.default',
    [
        'IBLOCK_ID' => $iblockId,
        'FILTER_NAME' => $filterName,
        'PRICE_CODE' => ['BASE'],
        'CACHE_TYPE' => 'A',
        'CACHE_TIME' => 3600,
        'CACHE_GROUPS' => 'N',
        'SAVE_IN_SESSION' => 'N',
    ],
    false
);
?>

<div class="catalog--layout">
    <aside class="catalog--sidebar">
        <?php // The smart filter template renders the panel here ?>
    </aside>

    <div class="catalog--content">
        <?php
        $APPLICATION->IncludeComponent(
            'custom:catalog.section.orm',
            '.default',
            [
                'IBLOCK_ID' => $iblockId,
                'FILTER_NAME' => $filterName,
                'PAGE_ELEMENT_COUNT' => 20,
                'SORT' => ['SORT' => 'ASC'],
            ],
            false
        );
        ?>
    </div>
</div>

Approach 2. Pre-select IDs and Pass Them into arrFilter

<?php

use Bitrix\Main\Loader;
use Bitrix\Main\Application;
use Bitrix\Main\Entity\Query;
use Bitrix\Main\Entity\ExpressionField;

Loader::includeModule('iblock');

$iblockId = 5;
$sectionId = 42;

$rootSection = \CIBlockSection::GetList(
    [],
    ['IBLOCK_ID' => $iblockId, 'ID' => $sectionId],
    false,
    ['ID', 'LEFT_MARGIN', 'RIGHT_MARGIN']
)->Fetch();

$sectionIds = [$sectionId];
if ($rootSection) {
    $rsSections = \CIBlockSection::GetList(
        ['LEFT_MARGIN' => 'ASC'],
        [
            'IBLOCK_ID' => $iblockId,
            '>LEFT_MARGIN' => (int)$rootSection['LEFT_MARGIN'],
            '<RIGHT_MARGIN' => (int)$rootSection['RIGHT_MARGIN'],
        ],
        false,
        ['ID']
    );
    while ($row = $rsSections->Fetch()) {
        $sectionIds[] = (int)$row['ID'];
    }
}

$query = new Query(\Bitrix\Iblock\ElementTable::getEntity());

$tableName = 'b_iblock_element_prop_m' . $iblockId;
$query->registerRuntimeField(
    'IN_BRAND',
    new ExpressionField(
        'IN_BRAND',
        'EXISTS (SELECT 1 FROM ' . $tableName
            . ' WHERE IBLOCK_ELEMENT_ID = %s'
            . ' AND IBLOCK_PROPERTY_ID = 1075'
            . ' AND VALUE_ENUM IN (101, 102))',
        ['ID']
    )
);

$sectionIdsSql = implode(',', array_map('intval', $sectionIds));
$query->registerRuntimeField(
    'IN_SECTION',
    new ExpressionField(
        'IN_SECTION',
        'EXISTS (SELECT 1 FROM b_iblock_section_element SE'
            . ' WHERE SE.IBLOCK_ELEMENT_ID = %s'
            . ' AND SE.IBLOCK_SECTION_ID IN (' . $sectionIdsSql . '))',
        ['ID']
    )
);

$query->setSelect(['ID']);
$query->setFilter([
    '=IBLOCK_ID' => $iblockId,
    '=ACTIVE' => 'Y',
    '=IN_BRAND' => 1,
    '=IN_SECTION' => 1,
]);
$query->setOrder(['SORT' => 'ASC']);

$result = $query->exec();
$filteredIds = [];
while ($row = $result->fetch()) {
    $filteredIds[] = (int)$row['ID'];
}

$arFilter = [
    'IBLOCK_ID' => $iblockId,
    'ID' => $filteredIds ?: [0],
];

What breaks: pagination on the entire ID array, sorting that gets re-applied by the component, and the template's own escaping.

Level 3. Smart Filter: Concrete Techniques

The stock catalog.smart.filter works acceptably over the facet index while the catalog is small. At a million products it starts to slow down because it recalculates counters on every user action. Below are four techniques that give a noticeable gain without rewriting the component.

Technique 1. Cache Counters Separately from Products

The heaviest part of the smart filter isn't selecting products but counting counters for every property value. That's a separate GROUP BY over the facet index, and its result changes only when product composition changes.

<?php

namespace Local\Catalog\SmartFilter;

use Bitrix\Iblock\PropertyEnumerationTable;
use Bitrix\Iblock\PropertyTable;
use Bitrix\Main\Application;
use Bitrix\Main\Data\Cache;
use Bitrix\Main\Loader;

class FacetCounterCache
{
    private const CACHE_TTL = 900;
    private const CACHE_PATH = '/catalog/smart.filter.facet/';
    private const MAX_ENUM_VALUES = 200;

    private int $iblockId;

    /** @var array Cache of table-existence checks */
    private static array $tableExistsCache = [];

    /** @var array Cache of property enum counts */
    private static array $enumCountCache = [];

    public function __construct(int $iblockId)
    {
        $this->iblockId = $iblockId;
        Loader::includeModule('iblock');
    }

    /**
     * Returns property-value counters for the current filter.
     * Cached for 15 minutes.
     *
     * @param array $activeFilter
     * @return array
     */
    public function getCounters(array $activeFilter): array
    {
        // ksort stabilises key order: without it, md5(serialize())
        // produces different cache keys for the same filter
        $sortedFilter = $activeFilter;
        if (!empty($sortedFilter['PROPERTY_VALUES']) && is_array($sortedFilter['PROPERTY_VALUES'])) {
            ksort($sortedFilter['PROPERTY_VALUES']);
        }

        $cache = Cache::createInstance();
        $cacheId = 'facet_' . $this->iblockId . '_' . md5(serialize($sortedFilter));

        if ($cache->initCache(self::CACHE_TTL, $cacheId, self::CACHE_PATH)) {
            return $cache->getVars();
        }

        $result = $this->buildCounters($sortedFilter);

        if ($cache->startDataCache()) {
            $cache->endDataCache($result);
        }

        return $result;
    }

    /**
     * Limits the value list to the top N by popularity.
     * Saves markup, but not database load.
     *
     * @param array $counters
     * @param int   $limit
     * @return array
     */
    public function limitTopValues(array $counters, int $limit = 30): array
    {
        if (count($counters) <= $limit) {
            return $counters;
        }

        usort($counters, static function (array $a, array $b): int {
            return $b['COUNT'] <=> $a['COUNT'];
        });

        return array_slice($counters, 0, $limit);
    }

    private function buildCounters(array $activeFilter): array
    {
        $facetTable = 'b_iblock_' . $this->iblockId . '_index';

        if (!$this->tableExists($facetTable)) {
            return [];
        }

        $properties = $this->loadListProperties();
        $result = [];

        foreach ($properties as $property) {
            $counters = $this->loadCountersForProperty($facetTable, $property, $activeFilter);
            if ($counters) {
                $result[$property['CODE']] = $counters;
            }
        }

        return $result;
    }

    private function loadCountersForProperty(string $facetTable, array $property, array $activeFilter): array
    {
        $connection = Application::getConnection();
        $sqlHelper = $connection->getSqlHelper();
        $propertyId = (int)$property['ID'];

        $sql = 'SELECT I.VALUE AS VALUE_ID, COUNT(DISTINCT I.ELEMENT_ID) AS CNT '
            . 'FROM ' . $sqlHelper->quote($facetTable) . ' I '
            . 'INNER JOIN b_iblock_facet F ON F.ID = I.FACET_ID '
            . 'WHERE F.PROPERTY_ID = ' . $propertyId
            . $this->buildExcludeClause($facetTable, $activeFilter, (string)$property['CODE'])
            . ' GROUP BY I.VALUE';

        $rows = $connection->query($sql)->fetchAll();
        if (!$rows) {
            return [];
        }

        $enumIds = array_column($rows, 'VALUE_ID');
        $enumMap = $this->loadEnumNames($propertyId, $enumIds);
        $result = [];

        foreach ($rows as $row) {
            $enumId = (int)$row['VALUE_ID'];
            if (!isset($enumMap[$enumId])) {
                continue;
            }

            $result[] = [
                'ID' => $enumId,
                'VALUE' => $enumMap[$enumId],
                'COUNT' => (int)$row['CNT'],
            ];
        }

        return $result;
    }

    /**
     * Builds constraints from other active filters. $excludeCode is
     * compared against string keys from the filter, but only PROPERTY_ID
     * fetched from the database is passed to SQL — injection is impossible
     * even if a key is forged.
     *
     * @param string $facetTable
     * @param array  $activeFilter
     * @param string $excludeCode
     * @return string
     */
    private function buildExcludeClause(string $facetTable, array $activeFilter, string $excludeCode): string
    {
        $sqlHelper = Application::getConnection()->getSqlHelper();
        $parts = [];
        $facetPropertyIds = $this->loadFacetPropertyIds();

        foreach ($activeFilter['PROPERTY_VALUES'] ?? [] as $code => $values) {
            if ($code === $excludeCode) {
                continue;
            }

            $propertyId = $this->getPropertyIdByCode((string)$code);
            if ($propertyId === 0 || !in_array($propertyId, $facetPropertyIds, true)) {
                continue;
            }

            $enumIds = array_map('intval', (array)$values);
            if (!$enumIds) {
                continue;
            }

            // EXISTS is usually faster than IN with a subquery over
            // the facet table, as it doesn't materialise an intermediate ID set
            $parts[] = 'EXISTS ('
                . 'SELECT 1 FROM ' . $sqlHelper->quote($facetTable) . ' X '
                . 'WHERE X.ELEMENT_ID = I.ELEMENT_ID '
                . 'AND X.FACET_ID IN (SELECT ID FROM b_iblock_facet WHERE PROPERTY_ID = ' . $propertyId . ') '
                . 'AND X.VALUE IN (' . implode(',', $enumIds) . '))';
        }

        return $parts ? ' AND ' . implode(' AND ', $parts) : '';
    }

    private function loadFacetPropertyIds(): array
    {
        $rows = Application::getConnection()->query(
            'SELECT DISTINCT PROPERTY_ID FROM b_iblock_facet WHERE IBLOCK_ID = ' . $this->iblockId
        )->fetchAll();

        return array_map(static fn($row) => (int)$row['PROPERTY_ID'], $rows ?: []);
    }

    private function getPropertyIdByCode(string $code): int
    {
        $row = PropertyTable::getList([
            'filter' => ['=IBLOCK_ID' => $this->iblockId, '=CODE' => $code],
            'select' => ['ID'],
            'limit' => 1,
        ])->fetch();

        return $row ? (int)$row['ID'] : 0;
    }

    private function loadListProperties(): array
    {
        $rows = PropertyTable::getList([
            'filter' => [
                '=IBLOCK_ID' => $this->iblockId,
                '=ACTIVE' => 'Y',
                '=PROPERTY_TYPE' => 'L',
                '=FILTRABLE' => 'Y',
            ],
            'select' => ['ID', 'CODE'],
        ])->fetchAll();

        if (!$rows) {
            return [];
        }

        $result = [];
        foreach ($rows as $row) {
            $propertyId = (int)$row['ID'];

            if (!isset(self::$enumCountCache[$propertyId])) {
                self::$enumCountCache[$propertyId] = (int)PropertyEnumerationTable::getCount([
                    '=PROPERTY_ID' => $propertyId,
                ]);
            }

            $enumCount = self::$enumCountCache[$propertyId];
            if ($enumCount > 0 && $enumCount <= self::MAX_ENUM_VALUES) {
                $result[] = $row;
            }
        }

        return $result;
    }

    private function loadEnumNames(int $propertyId, array $enumIds): array
    {
        if (!$enumIds) {
            return [];
        }

        $rows = PropertyEnumerationTable::getList([
            'filter' => [
                '=PROPERTY_ID' => $propertyId,
                '@ID' => array_map('intval', $enumIds),
            ],
            'select' => ['ID', 'VALUE'],
        ])->fetchAll();

        $map = [];
        foreach ($rows as $row) {
            $map[(int)$row['ID']] = (string)$row['VALUE'];
        }

        return $map;
    }

    /**
     * Checks whether a table exists. Result is cached statically —
     * SHOW TABLES doesn't use indexes and isn't free on large databases.
     *
     * @param string $table
     * @return bool
     */
    private function tableExists(string $table): bool
    {
        if (isset(self::$tableExistsCache[$table])) {
            return self::$tableExistsCache[$table];
        }

        $connection = Application::getConnection();

        // information_schema is faster than SHOW TABLES LIKE on large databases
        $row = $connection->query(
            'SELECT 1 FROM information_schema.TABLES '
            . 'WHERE TABLE_SCHEMA = DATABASE() '
            . 'AND TABLE_NAME = \'' . $connection->getSqlHelper()->forSql($table) . '\' '
            . 'LIMIT 1'
        )->fetch();

        self::$tableExistsCache[$table] = !empty($row);

        return self::$tableExistsCache[$table];
    }
}

Then in the smart filter template's result_modifier.php:

<?php

if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) {
    die();
}

use Local\Catalog\SmartFilter\FacetCounterCache;

$filterName = (string)($arParams['FILTER_NAME'] ?? '');
$activeFilter = ($filterName !== '' && isset($GLOBALS[$filterName]) && is_array($GLOBALS[$filterName]))
    ? $GLOBALS[$filterName]
    : [];

$cache = new FacetCounterCache((int)$arParams['IBLOCK_ID']);
$counters = $cache->getCounters($activeFilter);

foreach ($arResult['ITEMS'] as &$item) {
    $code = (string)($item['CODE'] ?? '');
    if ($code === '' || empty($counters[$code])) {
        continue;
    }

    $counterMap = [];
    foreach ($counters[$code] as $row) {
        $counterMap[(int)$row['ID']] = (int)$row['COUNT'];
    }

    foreach ($item['VALUES'] as &$value) {
        $id = (int)($value['ID'] ?? 0);
        if ($id > 0 && isset($counterMap[$id])) {
            $value['COUNT'] = $counterMap[$id];
        }
    }
    unset($value);

    // Top-30 by popularity — the rest via in-filter search
    $item['VALUES'] = $cache->limitTopValues($item['VALUES'], 30);
}
unset($item);

Technique 2. Direct Facet Index Reads Instead of EAV

If the stock smart filter doesn't use the facet index, you can query b_iblock_N_index directly. "Find all products where BRAND = 101 or 102 and COLOR = 15":

SELECT DISTINCT I.ELEMENT_ID
FROM b_iblock_5_index I
INNER JOIN b_iblock_facet F ON F.ID = I.FACET_ID
WHERE F.IBLOCK_ID = 5
  AND (
    (F.PROPERTY_ID = 1075 AND I.VALUE IN (101, 102))
    OR (F.PROPERTY_ID = 1076 AND I.VALUE = 15)
  )
GROUP BY I.ELEMENT_ID
HAVING COUNT(DISTINCT F.PROPERTY_ID) = 2;

HAVING COUNT(DISTINCT F.PROPERTY_ID) = 2 guarantees the product matches both properties, not just one.

Important: the check below answers only "does the table exist and is it non-empty". It doesn't answer "is the index fresh". On catalogs where product composition changes often, the index can lag by hours.

/**
 * Checks whether the facet index exists and has rows.
 * This is not a freshness check: the index can lag by hours.
 *
 * @param int $iblockId
 * @return bool
 */
protected function isFacetIndexReady(int $iblockId): bool
{
    $connection = Application::getConnection();
    $table = 'b_iblock_' . $iblockId . '_index';

    // information_schema is faster than SHOW TABLES on large databases
    $exists = $connection->query(
        'SELECT 1 FROM information_schema.TABLES '
        . 'WHERE TABLE_SCHEMA = DATABASE() '
        . 'AND TABLE_NAME = \'' . $connection->getSqlHelper()->forSql($table) . '\' '
        . 'LIMIT 1'
    )->fetch();

    if (!$exists) {
        return false;
    }

    $row = $connection->query(
        'SELECT 1 FROM ' . $connection->getSqlHelper()->quote($table) . ' LIMIT 1'
    )->fetch();

    return !empty($row);
}

On projects where freshness matters, store the last rebuild date in an option and compare it with the current date. If the gap is over a day, treat the index as suspect and fall back to the EAV path.

Technique 3. Cutting Heavy Properties from the Facet

Not every property belongs in the smart filter. Properties with tens of thousands of unique values (SKUs, barcodes) don't belong in a facet. The rule: if a property has more than 200 unique values, it isn't faceted. The check is built into loadListProperties(), and the count is cached in a static variable — on a catalog with 40 properties this saves 40 queries per call.

Technique 4. Limiting the Value List Length

limitTopValues() sorts values by popularity and keeps the top 30. This doesn't reduce database load but noticeably speeds up rendering.

Level 4. Caching: Composite Cache and More

Caching in catalog.section is where more swords have been broken than anywhere else.

Composite Cache

Composite cache is the main tool for removing load from MySQL in Bitrix, and it's rarely mentioned when talking about catalogs, because it isn't about generation speed — it's about generation not happening at all. A configured composite serves the page from a static HTML file without touching the database.

Enable it in the main module settings, "Composite Site" section. Three things matter for a catalog. First — static pages must be marked as composite via $APPLICATION->SetPageProperty('COMPOSITE', 'Y') or a site template setting. Second — dynamic blocks (cart, authorization, header counter) are extracted into "dynamic areas" via $APPLICATION->setFrameMode(true). Third — cache invalidation should happen on element-change events, not by time.

If everything is done right, a 500,000-SKU catalog page is served in 30–50 milliseconds even with a fully warmed database. The problem starts when the user sets a filter: composite pages aren't cached with $_GET, so any filter combination misses the cache and we hit MySQL again.

What to do: keep the page itself composite and load the filter result via a separate AJAX request. The composite then serves the frame instantly, and the heavy block with products and smart filter loads dynamically.

The CACHE_FILTER Parameter

catalog.section has a CACHE_FILTER parameter. If set to Y, the component caches the result including the filter. On a catalog with 50,000 products and 10 filters, that can produce thousands of cache files, each taking seconds to generate.

CACHE_FILTER = Y makes sense only if:

  • There are few filters (2–3 active parameters).
  • The catalog changes rarely.
  • The cache backend isn't files but memcached or Redis.

Tagged Cache

$cache = \Bitrix\Main\Data\Cache::createInstance();
$cacheId = 'catalog_filter_' . md5(serialize($arFilter));
$cachePath = '/catalog/filter/';
$cacheTime = 3600;

if ($cache->initCache($cacheTime, $cacheId, $cachePath)) {
    $arResult = $cache->getVars();
} elseif ($cache->startDataCache()) {
    $arResult = // heavy selection

    $cache->endDataCache($arResult);
}

To reset by tag, use \Bitrix\Main\Data\Cache::clearByTag(). On errors inside the heavy selection, call $cache->abortDataCache().

Level 5. MySQL Indexes Tuned for Bitrix

Bitrix stores infoblock properties in b_iblock_element_prop_* tables. With split storage (infoblocks 2.0), simple properties go to b_iblock_element_prop_s{ID}, multi-value ones to b_iblock_element_prop_m{ID}.

Which Index Is Needed for an EXISTS Subquery

In buildPropertyExists() the subquery looks like this:

EXISTS (
    SELECT 1 FROM b_iblock_element_prop_m5
    WHERE IBLOCK_ELEMENT_ID = <outer ID>
      AND IBLOCK_PROPERTY_ID = 1075
      AND VALUE_ENUM IN (101, 102, 103)
)

The IBLOCK_ELEMENT_ID = %s condition is correlated. The optimal index for such a subquery is (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE_ENUM). The columns go in order of selectivity: first element, then property, then value.

The stock Bitrix index is (VALUE_ENUM, IBLOCK_PROPERTY_ID). It works well for a different scenario: filtering from values to elements without correlation. That's the classic arrFilter scenario. But for the EXISTS subquery it's suboptimal because it doesn't cover IBLOCK_ELEMENT_ID.

This doesn't mean the stock index should be dropped — other queries depend on it. It means an additional index specific to the correlated condition is worth adding:

CREATE INDEX idx_prop_m14_elem_prop_enum
ON b_iblock_element_prop_m14 (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE_ENUM);

For string properties — same, with VALUE instead of VALUE_ENUM:

CREATE INDEX idx_prop_m14_elem_prop_value
ON b_iblock_element_prop_m14 (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE(32));

VALUE(32) is a prefix index on the first 32 characters. A full index on a long text column is pointless: selectivity doesn't grow, size grows quickly.

Before creating an index, check that it doesn't already exist: SHOW INDEX FROM b_iblock_element_prop_m14.

Facet Index

The stock Bitrix mechanism for speeding up filtering is the facet index. It lives in b_iblock_N_index: each row is a combination of property values for one product. The facet index needs to be rebuilt after bulk catalog changes.

Indexes for SKUs and Stock

For SKUs and stock tables, indexes are built separately. The "in stock only" filter's AMOUNT > 0 condition isn't part of the stock (PRODUCT_ID, STORE_ID) index — it's evaluated row by row. A composite (PRODUCT_ID, AMOUNT) index covers both.

Denormalization: When EAV Gets in the Way

If the catalog is large and filtering is a core scenario, move frequently-filtered properties into a separate flat table or a highload block with its own indexes. This doesn't abandon Bitrix: build a satellite table updated on the OnIBlockElementUpdate event and use it in a custom filter.

Level 6. When MySQL Can't Keep Up: Elasticsearch and Sphinx

All the previous levels are optimization inside MySQL. But there's a threshold beyond which any further SQL optimization yields less while maintenance complexity grows.

Elasticsearch: Faceted Aggregation Instead of JOIN

In Elasticsearch, each product is a document. Filtering goes through the index, not through JOIN. Counting products per filter value (faceted aggregation) runs as a single query.

Comparing MySQL and Elasticsearch on a 10,000-SKU catalog is pointless: with the facet index, MySQL responds in 30–80 milliseconds, and Elasticsearch won't improve on that. The gap starts at catalogs of 100,000+ products, where MySQL with the facet index consistently goes over 300–500 milliseconds, and at a million-product catalog with a dozen active filters — over 2–5 seconds.

At those volumes Elasticsearch delivers results in 80–150 milliseconds for any filter combination. The 5x to 30x gap reproduces reliably on real projects, but only starting from hundreds of thousands of products.

Sphinx: When You Need Morphology and Delta Indexing

Sphinx is an older technology, but it works well with Bitrix. Its main advantages are high indexing speed and morphology support. On an auto-parts catalog with 350,000 SKUs, after adopting Sphinx search time dropped from 8 seconds to 50 milliseconds.

When It's Time to Move Off MySQL

  • Up to 50,000 products and 3–4 active filters — the facet index plus composite MySQL indexes is enough.
  • 50,000–500,000 products — facet index, moving the heaviest properties to highload blocks, plus thoughtful caching.
  • 500,000+ products or 10+ active filters at once — time to look at Elasticsearch or Sphinx.

An external search engine isn't a replacement for MySQL, it's an addition. Bitrix remains the system of record; Elasticsearch or Sphinx handles only search and filtering.

Checklist: Step-by-Step

  1. Enable the performance monitor and find the slowest queries on the catalog page.
  2. Measure catalog.smart.filter and catalog.section separately.
  3. Feed the slowest queries into EXPLAIN ANALYZE. If actual rows differs from the estimate, refresh statistics or add an index.
  4. Run pt-query-digest against the slow query log and find the query with the largest share of total time.
  5. Check which property storage scheme the infoblock uses.
  6. Rewrite the heavy selection from CIBlockElement::GetList to D7 ORM, adding EXISTS subqueries for properties and sections.
  7. Make sure the custom component reads $GLOBALS[FILTER_NAME] and its structure matches what the smart filter writes. Check the name for collisions.
  8. Cache the smart filter's counters separately from the products. TTL 10–15 minutes.
  9. Cut properties with over 200 values from the facet.
  10. Check the template for XSS: wrap NAME, DETAIL_PAGE_URL, PREVIEW_TEXT in htmlspecialcharsbx().
  11. Configure composite cache: static frame plus AJAX-loaded filter and list.
  12. Review CACHE_FILTER: if there are many filters, turn it off and switch to tagged cache.
  13. Make sure the facet index is fresh. If the catalog changes often, set up rebuilds via an agent and freshness monitoring.
  14. Add composite indexes for EXISTS subqueries: (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE_ENUM) for list properties, (IBLOCK_ELEMENT_ID, IBLOCK_PROPERTY_ID, VALUE(32)) for string properties.
  15. If the catalog has 500,000+ products and more than ten filters, price out Elasticsearch or Sphinx.

Catalog optimization isn't a single magic setting. It's a sequence: find the bottleneck, rewrite the query, optimize the smart filter, enable composite cache, cover with caching, prop with indexes. And if all of that together still doesn't deliver — accept that MySQL has hit its architectural ceiling.

Aleksandr Tichiy
Author

Aleksandr Tichiy

Fullstack developer · 1C-Bitrix

I build and modernize online stores and corporate portals on 1C-Bitrix. Integrations with 1C, high-load performance, 152-FZ compliance. Writing about architecture and e-commerce.


Comments

There are no comments yet, you can be the first to leave it

Leave a comment

The site uses a comment pre-moderation system, so your message will be published only after approval by the moderator

You are replying to a user's comment

Send

FEEDBACK

Email me

Please message me to discuss the task. I work remotely and officially (as a self-employed professional); my hourly rate is 1,800 RUB, and I can dedicate 3–4 hours a day to the project. I respond quickly, and the consultation is free.

An error occurred while sending, please try again after a while
Message sent successfully

Phones

+7(993) 007-18-96

Email

info@tichiy.ru

Address

Россия, г. Москва

By submitting the form, you automatically confirm that you have read and accept the Privacy Policy site