Adobe Commerce B2B unlocks powerful functionality for wholesale and enterprise merchants — company accounts, shared catalogs, negotiable quotes, quick order, and requisition lists. But these features come with a significant performance cost if left unconfigured. In this post we dig into the most common B2B bottlenecks and how to systematically address each one.
Why B2B Is Different from B2C
Standard Magento 2 performance advice — Redis caching, Varnish, OPcache, flat tables — still applies to B2B. But B2B merchants face a set of additional challenges:
- Shared catalogs create per-company product visibility rules that bypass the standard catalog cache.
- Negotiable quotes generate large quote objects with complex price recalculations on every update.
- Company hierarchies add permission checks to nearly every storefront request for logged-in users.
-
Requisition lists issue repeated save/load queries against the
negotiable_quoteandrequisition_listtables.
The result: B2B storefronts often run 2–4× slower than equivalent B2C stores with no additional configuration.
1. Shared Catalog — The Hidden Query Bomb
Shared catalogs work by filtering product visibility per company via the shared_catalog_product_item table. Each catalog page load for a logged-in B2B customer hits this table with large JOIN queries.
What to profile
Run EXPLAIN on a category page query with shared catalogs enabled:
EXPLAIN SELECT e.entity_id, e.sku, e.name
FROM catalog_product_entity e
INNER JOIN shared_catalog_product_item sci
ON sci.sku = e.sku AND sci.customer_group_id = 5
WHERE e.status = 1;
Missing indexes on (sku, customer_group_id) are the most common culprit.
Fixes
Add a composite index if it's missing (check your version — Adobe Commerce 2.4.5+ has this):
ALTER TABLE shared_catalog_product_item
ADD INDEX IDX_SKU_GROUP (sku, customer_group_id);
Enable flat catalog for B2B stores with large catalogs (>50k SKUs). Despite being deprecated, flat tables still dramatically reduce EAV JOINs on category pages:
bin/magento config:set catalog/frontend/flat_catalog_product 1
bin/magento config:set catalog/frontend/flat_catalog_category 1
bin/magento indexer:reindex catalog_product_flat catalog_category_flat
Limit shared catalog depth. Every additional custom catalog multiplies reindex time and query complexity. Audit whether all catalogs are actually in use:
SELECT id, name, customer_group_id, created_at
FROM shared_catalog
ORDER BY created_at DESC;
Delete or merge unused catalogs via the Admin panel before they compound the problem.
2. Negotiable Quotes — Price Recalculation Is Expensive
Negotiable quotes are notorious for triggering full cart recalculations. Every time a sales rep adjusts a line item, Magento recalculates the entire quote: shipping estimates, tax, discounts, custom prices. On quotes with 200+ line items this can take 10–30 seconds.
Reduce recalculation triggers
In app/etc/config.php or via Admin, limit quote recalculation to explicit submit actions rather than on every save:
Stores → Configuration → B2B Features → Default B2B Payment Methods
More importantly, disable automatic quote totals recalculation on every admin edit. Patch the Magento\NegotiableQuote\Model\Quote\Totals class via a plugin to defer recalculation:
// Plugin: defer recalculation to explicit recalculate() call only
public function aroundRecalculateQuote(
\Magento\NegotiableQuote\Model\Quote\Totals $subject,
callable $proceed,
bool $force = false
): void {
if ($force) {
$proceed($force);
}
// Skip automatic recalculation triggered by save events
}
Apply carefully and test pricing accuracy thoroughly.
Index and archive old quotes
The quote and negotiable_quote tables grow unbounded on active B2B stores. Queries slow down as these tables exceed millions of rows.
-- Find quotes older than 6 months that are closed/declined
SELECT COUNT(*) FROM negotiable_quote nq
INNER JOIN quote q ON q.entity_id = nq.quote_id
WHERE nq.status IN ('closed', 'declined')
AND q.updated_at < DATE_SUB(NOW(), INTERVAL 6 MONTH);
Archive or purge stale quotes on a regular schedule using a custom cron job. Adobe Commerce does not do this automatically.
3. Company Account Permission Checks
Every request from a logged-in B2B user triggers a company permission check via Magento\Company\Model\Authorization. On storefronts with complex role hierarchies (company → division → user), this becomes expensive.
Cache company permissions
Company permission data changes rarely. Add a Redis-backed cache layer:
// In your around plugin on CompanyManagement::getByCustomerId()
$cacheKey = 'company_user_' . $customerId;
$cached = $this->cache->load($cacheKey);
if ($cached) {
return $this->serializer->unserialize($cached);
}
$result = $proceed($customerId);
$this->cache->save(
$this->serializer->serialize($result),
$cacheKey,
['company_permissions'],
3600
);
return $result;
Invalidate the tag company_permissions on company/role changes via an observer on company_save_after.
Minimize role tree depth
Deeply nested company structures (5+ levels) multiply permission checks. Encourage merchants to keep hierarchies flat: company → department → user (3 levels maximum). Beyond that, query count grows exponentially.
4. Requisition Lists — Silent Scalability Problem
Requisition lists seem lightweight but they issue a database read on every storefront visit for logged-in B2B users. Customers with 20+ requisition lists of 100+ items each generate dozens of queries per page.
Lazy-load requisition list counts
The storefront header displays a requisition list count. By default this is loaded synchronously. Move it to a separate AJAX call that runs after page load:
<!-- layout/default.xml -->
<block class="Magento\RequisitionList\Block\RequisitionList\Counter"
name="requisition.list.counter"
template="Magento_RequisitionList::counter.phtml">
<arguments>
<argument name="lazy_load" xsi:type="boolean">true</argument>
</arguments>
</block>
Implement the lazy-load controller endpoint at requisition_list/ajax/count and update the template to defer via fetch().
Paginate large requisition lists
A single requisition list with 500 items forces Magento to load all items before rendering. Add server-side pagination in your custom module:
$collection = $this->requisitionListItemRepository
->getList($searchCriteria->setPageSize(50)->setCurrentPage($page));
This alone can reduce TTFB on the requisition list page from 4s to under 800ms for large lists.
5. B2B Indexer Performance
B2B adds several indexers that run on top of the standard Magento indexers:
-
shared_catalog_product_price— runs on every shared catalog change -
b2b_company_structure— rebuilds company trees -
negotiable_quote_grid— refreshes the admin quote grid
These indexers are not always set to Update on Schedule out of the box.
Force schedule mode for all B2B indexers
bin/magento indexer:set-mode schedule shared_catalog_product_price
bin/magento indexer:set-mode schedule b2b_company_structure
bin/magento indexer:set-mode schedule negotiable_quote_grid
Verify the result:
bin/magento indexer:status | grep -E "shared_catalog|b2b_company|negotiable"
All B2B indexers should show Update by Schedule.
6. HTTP Cache Strategy for B2B
Full-page caching is effectively disabled for logged-in B2B customers — they see personalized catalogs, prices, and credit limits. This means your origin servers absorb every B2B request.
Session-less catalog pages
For large B2B stores, build a "guest view" of catalog pages that is FPC-cacheable, with personalized data (price, availability) loaded via AJAX after page paint. This is the same technique used in B2C "hole punching" but applied more aggressively.
HTTP/2 push for critical B2B assets
B2B pages load more JavaScript (quote widget, company menu, requisition controls). Configure Nginx or your CDN to HTTP/2 push core B2B JS bundles:
location = /b2b-account {
http2_push /pub/static/frontend/Magento/luma/en_US/Magento_NegotiableQuote/js/quote.min.js;
http2_push /pub/static/frontend/Magento/luma/en_US/Magento_Company/js/company.min.js;
}
7. Monitoring B2B Performance
Standard APM tools miss B2B-specific slow paths. Add custom instrumentation:
// In a plugin on NegotiableQuoteManagement::send()
$startTime = microtime(true);
$result = $proceed(...$args);
$elapsed = microtime(true) - $startTime;
if ($elapsed > 2.0) {
$this->logger->warning('Slow negotiable quote send', [
'quote_id' => $quoteId,
'elapsed_ms' => round($elapsed * 1000),
]);
}
return $result;
Track and alert on:
- Negotiable quote recalculation time > 3s
- Shared catalog reindex duration > 5 min
- Requisition list page TTFB > 1s
Summary: B2B Performance Checklist
| Area | Action | Impact |
|---|---|---|
| Shared catalog | Add composite index on (sku, customer_group_id)
|
High |
| Shared catalog | Archive unused catalogs | Medium |
| Negotiable quotes | Defer auto-recalculation | High |
| Negotiable quotes | Archive closed/declined quotes | Medium |
| Company permissions | Cache per-user permission data in Redis | High |
| Company structure | Keep hierarchy ≤ 3 levels deep | Medium |
| Requisition lists | Lazy-load header counter | Medium |
| Requisition lists | Paginate large lists | High |
| B2B indexers | Set all to Update by Schedule
|
High |
| HTTP caching | AJAX-load personalized data, cache catalog shell | High |
B2B performance is not a single-fix problem — it requires work across the stack. Start with indexer modes and the shared catalog index, as those yield the highest return with the lowest risk. Then work through quote archiving and company permission caching. With these changes in place, most B2B storefronts can achieve sub-second category pages even under authenticated load.
Top comments (0)