ony\Component\Console\Output\OutputInterface;
use Magento\Framework\View\LayoutInterface;
class HandleInventoryCommand extends Command
{
private LayoutInterface $layout;
public function __construct(LayoutInterface $layout)
{
parent::__construct();
$this->layout = $layout;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$handles = $this->layout->getUpdate()->getHandles();
$output->writeln(sprintf('Active handles: %d', count($handles)));
foreach ($handles as $handle) {
$output->writeln(sprintf(' [%s]', $handle));
}
return Command::SUCCESS;
}
}
**Rationale:** Targeting fewer than 20 handles per request keeps XML merge operations lightweight. If your inventory exceeds this threshold, audit third-party modules for unnecessary `layout.xml` files. Consolidate overlapping handles or migrate conditional rendering logic to PHP observers instead of XML declarations. Reducing handle count directly decreases the CPU overhead of the layout merge phase.
### Step 2: Refactor Block Instantiation Logic
Block constructors must strictly handle dependency injection. Heavy operations such as database queries, external API calls, or collection loads must never execute during object construction. Move these operations to lazy-loaded getters or the `_toHtml()` lifecycle method.
```php
// app/code/Commerce/Inventory/Block/AvailabilityIndicator.php
namespace Commerce\Inventory\Block;
use Magento\Framework\View\Element\Template;
use Commerce\Inventory\Api\StockLookupInterface;
class AvailabilityIndicator extends Template
{
private StockLookupInterface $stockLookup;
private ?array $cachedAvailability = null;
public function __construct(
Template\Context $context,
StockLookupInterface $stockLookup,
array $data = []
) {
parent::__construct($context, $data);
$this->stockLookup = $stockLookup;
}
public function fetchAvailability(): array
{
if ($this->cachedAvailability === null) {
$targetSku = $this->getData('target_sku');
$this->cachedAvailability = $this->stockLookup->retrieveStatus($targetSku);
}
return $this->cachedAvailability;
}
}
Rationale: Lazy evaluation ensures data fetching only occurs when the template explicitly invokes the getter. If the block is cached, conditionally removed, or never rendered, the provider never executes. This pattern eliminates redundant queries and prevents constructor-side effects from polluting the layout merge phase. It also aligns with Magento's block lifecycle, where _toHtml() is the appropriate boundary for final rendering logic.
Step 3: Implement Deterministic Fragment Caching
Magento's block cache operates independently of Full Page Cache (FPC). It stores rendered HTML fragments in the cache backend, keyed by configurable parameters. This is the highest-leverage optimization for static or semi-static UI components.
// app/code/Commerce/Catalog/Block/CategoryTile.php
namespace Commerce\Catalog\Block;
use Magento\Framework\View\Element\Template;
use Magento\Framework\App\Http\Context;
use Magento\Store\Model\StoreManagerInterface;
class CategoryTile extends Template
{
private Context $httpContext;
private StoreManagerInterface $storeManager;
public function getCacheLifetime(): int
{
return 3600;
}
public function getCacheKeyInfo(): array
{
return [
'COMM_CATEGORY_TILE',
$this->storeManager->getStore()->getId(),
$this->_design->getDesignTheme()->getThemeId(),
$this->httpContext->getValue(Context::CONTEXT_GROUP),
$this->getData('category_identifier') ?? 'default',
];
}
}
Rationale: Cache keys must capture every variable that influences output. Including store ID, theme, customer group, and contextual identifiers ensures cache isolation. Omitting dynamic parameters causes cache poisoning; including unnecessary ones causes cache fragmentation. Balance is critical. Use cache tags for invalidation rather than key variation to maintain backend efficiency.
Step 4: Offload Personalized Fragments via ESI
Blocks that vary per user, such as minicarts, welcome messages, or recently viewed products, break FPC. Edge Side Includes (ESI) allow Varnish to cache the main page shell while fetching personalized fragments asynchronously.
// app/code/Commerce/Customer/Block/MiniCartFragment.php
namespace Commerce\Customer\Block;
use Magento\Framework\View\Element\Template;
class MiniCartFragment extends Template
{
public function generateEsiEndpoint(): string
{
return $this->getUrl('customer/section/load', ['_secure' => true]);
}
public function getFragmentTtl(): int
{
return 300;
}
}
Template usage:
<esi:include src="<?= $block->generateEsiEndpoint() ?>" ttl="<?= $block->getFragmentTtl() ?>" />
Rationale: ESI decouples personalization from page caching. Varnish serves the cached shell instantly, then injects the ESI fragment via a lightweight internal request. This preserves FPC hit rates while maintaining dynamic user state. Ensure your Varnish configuration explicitly allows ESI processing and sets appropriate Surrogate-Capability headers.
Step 5: Defer Non-Critical UI Components
Below-the-fold components such as upsells, related products, or tabbed content do not require synchronous rendering. Replace them with lightweight placeholders and populate via AJAX after the initial paint.
<!-- app/design/frontend/Commerce/Theme/Magento_Catalog/layout/catalog_product_view.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceBlock name="product.info.upsell" remove="true"/>
<referenceContainer name="product.info.main">
<block class="Magento\Framework\View\Element\Template" name="upsell.placeholder" template="Commerce_Theme::catalog/product/upsell-placeholder.phtml" after="-"/>
</referenceContainer>
</body>
</page>
Rationale: Moving collection loads off the critical path reduces TTFB significantly. The browser renders the primary content first, then fetches secondary data asynchronously. This improves perceived performance, reduces server-side render time, and aligns with modern Core Web Vitals metrics. Implement a loading skeleton in the placeholder template to maintain visual stability during hydration.
Pitfall Guide
1. Global Layout Pollution
Explanation: Developers routinely add blocks to default.xml to guarantee visibility across all pages. This forces instantiation on every request, including checkout, account pages, and API routes.
Fix: Scope blocks to specific layout handles (catalog_product_view.xml, cms_index_index.xml). Use layout XML ifconfig attributes or PHP observers to conditionally render components. Audit default.xml quarterly and remove any block that does not appear on 90%+ of pages.
Explanation: <remove name="block.name"/> does not prevent instantiation. Magento still builds the object, runs its constructor, and then discards the output during the rendering phase.
Fix: Use <referenceBlock name="block.name" remove="true"/> to prevent rendering at the layout merge stage. For permanent removal, override the parent layout and omit the block declaration entirely. Verify removal using the handle inventory command to ensure the block never enters the registry.
3. Lifecycle Hook Abuse
Explanation: _prepareLayout() executes during layout assembly, before cache checks or template rendering. Heavy logic here runs even if the block output is cached or never displayed.
Fix: Move data fetching to lazy getters or _toHtml(). Reserve _prepareLayout() strictly for adding page titles, breadcrumbs, or meta tags. If a block requires complex setup, inject a dedicated service class and call it only when rendering is confirmed.
Explanation: Each widget instance triggers a database query to load configuration and an additional layout merge. Stores with 50+ widgets accumulate significant per-request overhead.
Fix: Replace frequently used widgets with hardcoded blocks or static HTML. Audit the widget_instance table and consolidate redundant configurations. Use CMS blocks with static content where dynamic logic isn't required. Migrate complex widgets to custom block classes with deterministic caching.
5. Cache Key Entropy
Explanation: Including dynamic or unique identifiers (like session IDs or timestamps) in getCacheKeyInfo() causes cache fragmentation. The backend stores thousands of near-identical entries, exhausting memory and reducing hit rates.
Fix: Limit cache keys to deterministic variables: store ID, theme, customer group, and explicit block parameters. Use cache tags for invalidation instead of key variation. Implement a cache key validator in your development pipeline to flag non-deterministic entries.
6. Runtime XML Injection
Explanation: Calling $layout->getUpdate()->addUpdate() with runtime-generated XML breaks layout merge caching. Magento treats each unique XML string as a new layout, bypassing the cache entirely.
Fix: Generate dynamic content in PHP blocks, not XML. Use layout XML for structural declarations and PHP for conditional logic. If runtime modifications are unavoidable, cache the resulting layout handle and reuse it across identical requests.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High-traffic catalog listing | Selective Block Caching + Deferred UI | Reduces DB load, improves LCP, maintains FPC compatibility | Low infrastructure cost, moderate dev effort |
| Checkout flow | Minimal Layout Tree + No Caching | Checkout requires real-time state; caching introduces risk | High CPU tolerance required, but traffic volume is low |
| CMS marketing pages | Full Page Cache + ESI for personalization | Static content benefits most from FPC; ESI handles dynamic banners | Lowest TTFB, highest cache hit rate |
| Personalized dashboard | ESI Fragmentation + AJAX Hydration | User-specific data cannot be cached globally; fragments isolate state | Moderate Redis usage, requires Varnish configuration |
Configuration Template
Copy this structure to implement deterministic block caching with proper invalidation:
<?php
namespace Commerce\Performance\Block;
use Magento\Framework\View\Element\Template;
use Magento\Framework\App\Cache\Type\Block as CacheTypeBlock;
use Magento\Framework\App\Http\Context;
use Magento\Store\Model\StoreManagerInterface;
class DeterministicFragment extends Template
{
private Context $httpContext;
private StoreManagerInterface $storeManager;
public function __construct(
Template\Context $context,
Context $httpContext,
StoreManagerInterface $storeManager,
array $data = []
) {
parent::__construct($context, $data);
$this->httpContext = $httpContext;
$this->storeManager = $storeManager;
}
public function getCacheLifetime(): int
{
return 7200; // 2 hours
}
public function getCacheKeyInfo(): array
{
return [
'PERF_DETERMINISTIC_FRAGMENT',
$this->storeManager->getStore()->getId(),
$this->_design->getDesignTheme()->getThemeId(),
$this->httpContext->getValue(Context::CONTEXT_GROUP),
$this->getData('fragment_identifier') ?? 'default',
];
}
public function getCacheTags(): array
{
return [CacheTypeBlock::CACHE_TAG, 'PERF_FRAGMENT_GROUP'];
}
}
Quick Start Guide
- Audit: Execute the handle inventory CLI command on your production environment. Export the handle lists for your top 5 pages.
- Prune: Identify blocks in
default.xml that do not appear on every page. Move them to specific layout handles or remove them entirely.
- Cache: Implement
getCacheKeyInfo() and getCacheLifetime() on your top 10 semi-static blocks. Ensure keys exclude session IDs or timestamps.
- Defer: Replace below-the-fold collection blocks with placeholder templates. Add a lightweight JavaScript fetch to hydrate the content after
DOMContentLoaded.
- Verify: Clear the layout cache (
bin/magento cache:clean layout), run a load test, and monitor TTFB, cache hit rates, and CPU utilization. Adjust TTLs and key composition based on observed fragmentation.