<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Magevanta</title>
    <description>The latest articles on DEV Community by Magevanta (@magevanta).</description>
    <link>https://dev.to/magevanta</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3887629%2F7af145fd-03e9-4362-99dc-a5f637f09ce1.png</url>
      <title>DEV Community: Magevanta</title>
      <link>https://dev.to/magevanta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/magevanta"/>
    <language>en</language>
    <item>
      <title>Magento 2 RabbitMQ Performance: Tuning Consumers and the Broker for High-Volume Stores</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Thu, 10 Sep 2026 09:05:31 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-rabbitmq-performance-tuning-consumers-and-the-broker-for-high-volume-stores-47fb</link>
      <guid>https://dev.to/magevanta/magento-2-rabbitmq-performance-tuning-consumers-and-the-broker-for-high-volume-stores-47fb</guid>
      <description>&lt;p&gt;RabbitMQ is the invisible engine behind Magento 2's asynchronous work: bulk REST operations, async endpoints, order emails, product alerts, inventory reservation cleanup and B2B quote and shared-catalog updates all travel through message queues. The storefront can be perfectly fast while the queues silently back up — orders confirmed, but emails arriving an hour late, bulk operations stuck on "processing", and &lt;code&gt;inventory_reservation&lt;/code&gt; rows growing because the cleanup consumer never caught up.&lt;/p&gt;

&lt;p&gt;This guide is about the layer most performance audits skip: the broker and the consumers that drain it. You will learn how to read RabbitMQ's diagnostics, where the real bottlenecks hide (usually the consumers, sometimes the broker, rarely both), and a concrete tuning playbook that scales from a single node to a high-volume store.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Magento uses RabbitMQ
&lt;/h2&gt;

&lt;p&gt;Magento defines queues through three XML files per module: &lt;code&gt;queue_topology.xml&lt;/code&gt; (exchanges, queues, bindings), &lt;code&gt;queue_consumer.xml&lt;/code&gt; (which consumer processes which queue, with what handler) and &lt;code&gt;communication.xml&lt;/code&gt; (which topic routes to which handler). Messages published under a topic land in the broker, and a consumer process picks them up, calls the handler, and acknowledges the message.&lt;/p&gt;

&lt;p&gt;Everything is opt-in per queue: you can run consumers per queue yourself with &lt;code&gt;bin/magento queue:consumers:start&lt;/code&gt;, and heavy modules (async order processing, B2B, inventory) add their own consumers. If a consumer is never started, its queue simply fills up. That is the most common "RabbitMQ problem" on real stores: not a broker issue, a missing or under-provisioned consumer. See the &lt;a href="https://magevanta.com/blog/magento-2-async-operations-message-queues" rel="noopener noreferrer"&gt;async operations and message queues overview&lt;/a&gt; for the full architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnose before you tune
&lt;/h2&gt;

&lt;p&gt;Never guess where the backlog is. RabbitMQ ships everything you need on the node itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Per-queue depth and consumer state — your primary view&lt;/span&gt;
rabbitmqctl list_queues name messages messages_ready messages_unacknowledged consumers

&lt;span class="c"&gt;# Who is connected and how many channels they hold&lt;/span&gt;
rabbitmqctl list_connections name channel_max client_properties

&lt;span class="c"&gt;# Per-consumer state: which queue, how many messages it holds&lt;/span&gt;
rabbitmqctl list_consumers

&lt;span class="c"&gt;# Broker health: memory, disk, alarms, file descriptors&lt;/span&gt;
rabbitmq-diagnostics memory
rabbitmq-diagnostics alarms
rabbitmqctl status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Magento side, get the list of consumers and confirm which are actually running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento queue:consumers:list
ps aux | &lt;span class="nb"&gt;grep &lt;/span&gt;queue:consumers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the numbers like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;messages_ready&lt;/code&gt; high while consumers &amp;gt; 0&lt;/strong&gt;: the handlers are too slow or there are too few consumers. Add workers or optimize the handler — more on both below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;messages_unacknowledged&lt;/code&gt; high&lt;/strong&gt;: consumers are holding messages mid-processing, crashed mid-message, or stuck in a redelivery loop. Check consumer logs for repeated exceptions and look at handler duration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory or disk alarm active&lt;/strong&gt;: the broker is throttling publishers and consumers. Fix the broker first (see below); tuning consumers while the broker is in flow-control is pointless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumers = 0 on a queue that should always be drained&lt;/strong&gt;: the consumer died and nothing restarted it — a process-manager problem, not a tuning problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also correlate with the database. A queue backlog usually shows up as side effects: &lt;code&gt;inventory_reservation&lt;/code&gt; growth when the cleanup consumer lags (see the &lt;a href="https://magevanta.com/blog/magento-2-inventory-reservation-performance-optimization" rel="noopener noreferrer"&gt;inventory reservation deep dive&lt;/a&gt;), or an exploding &lt;code&gt;bulk&lt;/code&gt;/&lt;code&gt;operation&lt;/code&gt; table when &lt;code&gt;async.operations.all&lt;/code&gt; is stuck (see &lt;a href="https://magevanta.com/blog/magento-2-sales-order-performance-optimization" rel="noopener noreferrer"&gt;sales order performance&lt;/a&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Tune the consumers — the biggest win
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Decide: wait or exit
&lt;/h3&gt;

&lt;p&gt;By default a Magento consumer exits when the queue is empty or after &lt;code&gt;--max-messages&lt;/code&gt;. Frequent exit/restart churns connections and delays message processing. Set &lt;code&gt;consumers_wait_for_messages&lt;/code&gt; in &lt;code&gt;app/etc/env.php&lt;/code&gt; so consumers stay alive and wait for new messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'consumers_wait_for_messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Run more than one process per queue
&lt;/h3&gt;

&lt;p&gt;A single consumer is usually single-threaded PHP. Throughput scales with worker count until you saturate the handler's real bottleneck (database writes, API calls, filesystem). Run several processes for the queues that matter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento queue:consumers:start async.operations.all &lt;span class="nt"&gt;--max-messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000 &amp;amp;
bin/magento queue:consumers:start async.operations.all &lt;span class="nt"&gt;--max-messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000 &amp;amp;
bin/magento queue:consumers:start async.operations.all &lt;span class="nt"&gt;--max-messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10000 &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For long-lived setups, declare parallelism in &lt;code&gt;app/etc/env.php&lt;/code&gt; instead of juggling background processes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="s1"&gt;'queue'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'consumers_wait_for_messages'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'consumers'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'async.operations.all'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'multiple_processes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Magento 2.4.6+ goes further with declarative consumer settings in &lt;code&gt;app/etc/consumers.xml&lt;/code&gt;, where you can cap runtime per process per consumer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;config&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;consumer&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"async.operations.all"&lt;/span&gt;
              &lt;span class="na"&gt;maxMessages=&lt;/span&gt;&lt;span class="s"&gt;"10000"&lt;/span&gt;
              &lt;span class="na"&gt;maxExecutionTime=&lt;/span&gt;&lt;span class="s"&gt;"3600"&lt;/span&gt;
              &lt;span class="na"&gt;maxIdleTime=&lt;/span&gt;&lt;span class="s"&gt;"60"&lt;/span&gt;
              &lt;span class="na"&gt;multipleProcesses=&lt;/span&gt;&lt;span class="s"&gt;"3"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/config&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the command line the same caps exist since 2.4.6 as &lt;code&gt;--max-execution-time&lt;/code&gt; and &lt;code&gt;--max-idle-time&lt;/code&gt;, next to the classic &lt;code&gt;--max-messages&lt;/code&gt; and &lt;code&gt;--batch-size&lt;/code&gt;. Pick your poison; the declarative file survives deploys and is reviewable, which is why we prefer it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Cap messages per process — memory control
&lt;/h3&gt;

&lt;p&gt;Queue consumers are long-running PHP, and long-running PHP accumulates memory: object managers, singletons and collection instances that never get released. A consumer that runs for days will quietly grow from 120 MB to a gigabyte before crashing — we covered the mechanics in &lt;a href="https://magevanta.com/blog/magento-2-memory-leaks-cron-queue-consumers" rel="noopener noreferrer"&gt;memory leaks in cron jobs &amp;amp; queue consumers&lt;/a&gt;. The standard fix is the bounded worker: &lt;code&gt;--max-messages&lt;/code&gt; (and &lt;code&gt;--max-execution-time&lt;/code&gt;) so each process restarts before memory becomes a problem. &lt;code&gt;10000&lt;/code&gt; messages or &lt;code&gt;3600&lt;/code&gt; seconds per process is a sane starting point; watch RSS over a week and tighten until restarts cost less than the leaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Respect ordering and idempotency
&lt;/h3&gt;

&lt;p&gt;Multiple consumers on one queue mean messages can be processed out of order and retried. Most Magento queues tolerate that (bulk operations, emails, reservations), but the handlers must be idempotent: a message redelivered after a crash (&lt;code&gt;redelivered=true&lt;/code&gt;) must not double-apply. If you have a queue where order truly matters, run a single process for it and accept the throughput ceiling.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Stop poison-message loops
&lt;/h3&gt;

&lt;p&gt;A handler that always throws (bad payload, missing product, persistent API error) gets the message rejected and redelivered forever — the consumer logs the same exception in a tight loop, hammering the broker and the database while &lt;code&gt;messages_unacknowledged&lt;/code&gt; climbs. Two proven fixes: make the handler safe against bad input (validate, catch, log-and-skip), and add a dead-letter exchange in &lt;code&gt;queue_topology.xml&lt;/code&gt; so repeated failures land in a quarantine queue you can inspect instead of looping.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tune the broker
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Right-size the node
&lt;/h3&gt;

&lt;p&gt;RabbitMQ is memory- and I/O-hungry. Give it its own VM or container; do not share a disk with MySQL, and never let the broker swap. For high-volume stores, 4 GB+ RAM and SSDs are a floor, not a luxury — the &lt;a href="https://magevanta.com/blog/magento-2-load-testing-capacity-planning" rel="noopener noreferrer"&gt;load testing &amp;amp; capacity planning guide&lt;/a&gt; has a method for finding how much of this you actually need.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory watermark — the alarm everyone hits
&lt;/h3&gt;

&lt;p&gt;The broker blocks publishers (flow control) when memory exceeds &lt;code&gt;vm_memory_high_watermark&lt;/code&gt;, &lt;strong&gt;0.4 (40% of RAM) by default&lt;/strong&gt;. On a box with 8 GB that means flow control starts at 3.2 GB — which on stores with deep queues, many consumers and default channel buffers happens sooner than you think. Tune it deliberately, not as a knee-jerk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# rabbitmq.conf
&lt;/span&gt;&lt;span class="py"&gt;vm_memory_high_watermark.absolute&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6GB&lt;/span&gt;
&lt;span class="py"&gt;disk_free_limit.absolute&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;2GB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same logic applies to disk: &lt;code&gt;disk_free_limit&lt;/code&gt; defaults to 50 MB, which is far too little for a busy store — a full disk freezes everything. Set an absolute limit you can live with.&lt;/p&gt;

&lt;h3&gt;
  
  
  File descriptors and connection limits
&lt;/h3&gt;

&lt;p&gt;Every consumer connection, channel and queued message can eat file descriptors. The classic failure is consumers that cannot reconnect because the broker hit its FD ceiling. Raise the system &lt;code&gt;ulimit&lt;/code&gt; (65535 or higher) and verify with &lt;code&gt;rabbitmqctl status&lt;/code&gt; — the File descriptor count/limit line. If your consumers each hold multiple channels, also check &lt;code&gt;channel_max&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Heartbeats
&lt;/h3&gt;

&lt;p&gt;RabbitMQ's default heartbeat is 60 seconds. Magento's AMQP client can run with heartbeat 0 (disabled), which means network equipment can silently kill idle consumer connections — the queue looks drained, the consumer looks alive, and nobody is doing any work. Configure heartbeat explicitly in the AMQP section of &lt;code&gt;app/etc/env.php&lt;/code&gt; (for example &lt;code&gt;'heartbeat' =&amp;gt; 60&lt;/code&gt;) and monitor &lt;code&gt;list_connections&lt;/code&gt; for the &lt;code&gt;timeout&lt;/code&gt; column so churn is visible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quorum queues for durability, classic for throughput
&lt;/h3&gt;

&lt;p&gt;Magento's default topology uses classic queues, which are the fastest choice for a single-node broker. If you run RabbitMQ in a cluster, know that mirrored classic queues are deprecated and &lt;strong&gt;removed in RabbitMQ 4.0&lt;/strong&gt; — plan for quorum queues (RabbitMQ 3.8+), which replicate and can survive node loss. Quorum queues cost write amplification, so use them for the critical queues (order processing, B2B) and keep classic queues for throwaway work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run consumers properly in production
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;consumers_runner&lt;/code&gt; cron job Magento ships is fine for keeping a low-volume store's consumers alive, but for anything serious use a process manager. systemd or supervisord gives you autostart, autorestart with proper backoff, &lt;code&gt;startsecs&lt;/code&gt; crash detection, logging, and resource limits per consumer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[program:magento_async_operations]&lt;/span&gt;
&lt;span class="py"&gt;command&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;php /var/www/magento/bin/magento queue:consumers:start async.operations.all --max-messages=10000&lt;/span&gt;
&lt;span class="py"&gt;user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;www-data&lt;/span&gt;
&lt;span class="py"&gt;autostart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autorestart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;startsecs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10&lt;/span&gt;
&lt;span class="py"&gt;numprocs&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;
&lt;span class="py"&gt;process_name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;%(program_name)s_%(process_num)02d&lt;/span&gt;
&lt;span class="py"&gt;redirect_stderr&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notes from real deployments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Give consumers their own PHP-FPM pool settings equivalent — they compete with web traffic for CPU and MySQL. On a busy store, cap worker counts so a backlog drain doesn't starve the storefront; see &lt;a href="https://magevanta.com/blog/magento-2-php-fpm-tuning" rel="noopener noreferrer"&gt;PHP-FPM tuning&lt;/a&gt; for the same calculation applied to web workers.&lt;/li&gt;
&lt;li&gt;When a backlog builds up (flash sale, failed consumer overnight), add temporary workers rather than restarting the broker. Draining a 200k-message queue is a throughput problem: workers, not magic.&lt;/li&gt;
&lt;li&gt;Alert on what matters: queue depth and message age exceeding a threshold, &lt;code&gt;messages_unacknowledged&lt;/code&gt; growth, memory/disk alarms, and &lt;code&gt;consumers = 0&lt;/code&gt; for critical queues. Every alert needs a runbook action, or it becomes noise you ignore.&lt;/li&gt;
&lt;li&gt;Test under load before raising worker counts in production. Email sends and API calls have their own ceilings — blasting 10 workers at a slow SMTP relay makes the backlog worse, not better. The &lt;a href="https://magevanta.com/blog/magento-2-email-performance-optimization" rel="noopener noreferrer"&gt;email performance guide&lt;/a&gt; shows the same pattern for outbound sending.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Playbook summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure first&lt;/strong&gt;: &lt;code&gt;rabbitmqctl list_queues&lt;/code&gt; for ready/unacked depth, &lt;code&gt;rabbitmq-diagnostics alarms&lt;/code&gt;, &lt;code&gt;queue:consumers:list&lt;/code&gt; against &lt;code&gt;ps aux&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;consumers_wait_for_messages=1&lt;/code&gt;&lt;/strong&gt; so consumers stop exit/restart churn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale consumers&lt;/strong&gt;: &lt;code&gt;multiple_processes&lt;/code&gt; per queue in env.php, or &lt;code&gt;consumers.xml&lt;/code&gt; (2.4.6+) with &lt;code&gt;maxMessages&lt;/code&gt;/&lt;code&gt;maxExecutionTime&lt;/code&gt;/&lt;code&gt;maxIdleTime&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound memory&lt;/strong&gt;: &lt;code&gt;--max-messages&lt;/code&gt;/&lt;code&gt;--max-execution-time&lt;/code&gt; caps so processes restart before leaking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kill poison loops&lt;/strong&gt;: validate and catch in handlers; dead-letter queue for repeated failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right-size the broker&lt;/strong&gt;: dedicated node, explicit &lt;code&gt;vm_memory_high_watermark&lt;/code&gt; and &lt;code&gt;disk_free_limit&lt;/code&gt;, raised file descriptors, configured heartbeats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run under a process manager&lt;/strong&gt; with alerting on depth, age, unacked, alarms and zero-consumer states.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test under load&lt;/strong&gt; before scaling workers, and watch the downstream bottleneck (DB, SMTP, APIs), not just queue depth.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A healthy RabbitMQ setup is boring: queues near zero, consumers stable for weeks, no alarms. If yours is exciting, work through the diagnosis first — the backlog is almost always a consumer problem wearing a broker costume.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Memory Leaks in Cron Jobs &amp; Queue Consumers: Find, Fix and Prevent Them</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Wed, 09 Sep 2026 09:04:14 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-memory-leaks-in-cron-jobs-queue-consumers-find-fix-and-prevent-them-2mn9</link>
      <guid>https://dev.to/magevanta/magento-2-memory-leaks-in-cron-jobs-queue-consumers-find-fix-and-prevent-them-2mn9</guid>
      <description>&lt;p&gt;Cron jobs and queue consumers are the background engine of a Magento 2 store — and the most common place where memory problems hide. A web request lives for a second and dies, taking its garbage with it. A queue consumer or a heavy cron job can run for hours, and every leak it accumulates stays in the process until it crashes.&lt;/p&gt;

&lt;p&gt;The typical story: the consumer worked fine on Monday. By Wednesday it has processed 40,000 messages, its RSS has climbed from 120 MB to 900 MB, and at 2 AM it slams into &lt;code&gt;memory_limit&lt;/code&gt; and dies. Messages pile up, the email backlog grows, and the only clue in the logs is &lt;code&gt;Allowed memory size of 262144000 bytes exhausted&lt;/code&gt; with a stack trace that points nowhere useful.&lt;/p&gt;

&lt;p&gt;This guide explains why Magento leaks in long-running processes, how to prove it, and a fix playbook you can apply today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PHP processes grow without bound
&lt;/h2&gt;

&lt;p&gt;PHP is not inherently leak-prone for short requests. It frees memory by reference counting: when an object's refcount drops to zero, the memory is reclaimed immediately.&lt;/p&gt;

&lt;p&gt;Three things break that model in long-running processes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Circular references.&lt;/strong&gt; Object A holds a reference to B, B holds one back to A. Neither ever reaches zero. PHP's cycle collector (&lt;code&gt;gc_enable()&lt;/code&gt;) exists to break these, but it only helps if it actually runs, and Magento is full of cycles: product ↔ stock item, order ↔ order items, plugin proxies capturing their targets.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Static and singleton state.&lt;/strong&gt; A web request dies and everything is freed. A consumer doesn't. Anything stored in a static property, a singleton, or the object manager lives until the process restarts. Magento 2 is built on the object manager and singletons, so a single line of extension code that stashes a collection "for later" becomes a permanent retention point.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Collection internals.&lt;/strong&gt; &lt;code&gt;Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection&lt;/code&gt; keeps every loaded entity in both &lt;code&gt;_items&lt;/code&gt; and &lt;code&gt;_itemsById&lt;/code&gt;. If your loop loads 100,000 products into one collection, all 100,000 objects stay referenced until the collection is cleared or falls out of scope — which in a long-lived loop may be never.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The Magento-specific retention points
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ObjectManager &amp;amp; singleton instances&lt;/strong&gt; — the DI container keeps whatever it created, for the lifetime of the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event dispatch&lt;/strong&gt; — observers that capture objects into static arrays or into other long-lived objects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loggers and the profiler&lt;/strong&gt; — see below, this one is easy to hit accidentally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message queue internals&lt;/strong&gt; — consumer loops that keep the last message object (or an array of processed messages) referenced until the next iteration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caches with static backing&lt;/strong&gt; — any "cache in a static array" pattern (common in third-party modules and cache warmers) grows one entry at a time, forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Detect it: measure RSS over time, not peak
&lt;/h2&gt;

&lt;p&gt;A leak is a &lt;em&gt;trend&lt;/em&gt;, so a single measurement proves nothing. You need a time series:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Watch a running consumer's memory grow (or not)&lt;/span&gt;
&lt;span class="nv"&gt;CONSUMER_PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"queue:consumers:start"&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;ps &lt;span class="nt"&gt;-o&lt;/span&gt; pid,rss,vsz,etime,cmd &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONSUMER_PID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;10
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For cron runs, add a memory checkpoint at the end of your own jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Job %s done: peak=%dMB, end=%dMB'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;$jobName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;memory_get_peak_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;memory_get_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting the trend lets you classify the shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stable plateau, then a step up&lt;/strong&gt; — an event-based retention (e.g. every N messages something is cached).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linear climb&lt;/strong&gt; — a per-iteration leak; each message or row retains a fixed amount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sawtooth but drifting upward&lt;/strong&gt; — the cycle collector runs and reclaims, but retention outpaces it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peak-only tools lie&lt;/strong&gt; — &lt;code&gt;/usr/bin/time -v&lt;/code&gt; shows peak RSS for the whole run, which for a well-behaved job is high anyway. For leak hunting, profile over time with Blackfire or xhprof memory graphs, or sample RSS as above.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The classic leak scenarios on real stores
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Consumers running forever.&lt;/strong&gt; &lt;code&gt;bin/magento queue:consumers:start &amp;lt;name&amp;gt;&lt;/code&gt; without &lt;code&gt;--max-messages&lt;/code&gt; runs until killed. Every per-message retention compounds hour after hour. This is the single most common cause of consumer OOMs. Restart them on a schedule (see the playbook).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The profiler left on.&lt;/strong&gt; If &lt;code&gt;dev/debug/profiler&lt;/code&gt; is enabled (or a profiler module is active), &lt;code&gt;Magento\Framework\Profiler&lt;/code&gt; accumulates timing data for every event, timer, and query in memory. In a long-running process this alone can add hundreds of MB. It's a dev tool — keep it off in production, and never enable it on a consumer that you don't plan to restart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Whole-collection loops.&lt;/strong&gt; Loading a full collection and iterating it is fine in a web request, lethal in a cron job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: every product stays referenced in the collection&lt;/span&gt;
&lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productCollectionFactory&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$products&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addAttributeToSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// full EAV rows&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$products&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;doSomething&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Import scripts that slurp files.&lt;/strong&gt; &lt;code&gt;file($csvPath)&lt;/code&gt; on a 500 MB export and &lt;code&gt;$product-&amp;gt;load($sku)&lt;/code&gt; per row without clearing — peak memory explodes and nothing gets reclaimed between rows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Indexer or scheduler plugins.&lt;/strong&gt; Reindex processes already batch internally in 2.4.x, but extensions hooked onto indexer events (catalog permissions, staging, custom price dimensions) often retain per-batch data in static arrays. Profile the reindex with xhprof and look at who holds references after each batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix playbook
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Bound every long-running process
&lt;/h3&gt;

&lt;p&gt;For consumers, never rely on "it's been fine for months":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento queue:consumers:start order_emails &lt;span class="nt"&gt;--max-messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;500
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--max-messages&lt;/code&gt; exists since Magento 2.3 and cleanly exits after N messages; recent 2.4 releases also support &lt;code&gt;--max-execution-time&lt;/code&gt;. Pair this with a process supervisor (Supervisor, systemd, or the &lt;code&gt;cron_consumers_runner&lt;/code&gt; config) that respawns the consumer immediately — then a bounded restart is invisible to your queue. This turns "memory climbs forever" into "memory climbs for 500 messages, then a fresh process starts".&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Batch and clear collections
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$collection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;productCollectionFactory&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addAttributeToSelect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setPageSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$lastPage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getLastPageNumber&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$page&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nv"&gt;$lastPage&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setCurPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$collection&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;doSomething&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nv"&gt;$collection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// release _items and _itemsById&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-&amp;gt;clear()&lt;/code&gt; is the key line: it drops the internal item arrays so the next batch starts from a clean slate.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Unset and collect cycles in custom loops
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$processed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$queue&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="nv"&gt;$processed&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nb"&gt;gc_collect_cycles&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// break circular references now&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling &lt;code&gt;gc_collect_cycles()&lt;/code&gt; periodically is cheap insurance in any long-running loop; it lets the cycle collector sweep instead of letting garbage accumulate until the process dies.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Ban static retention in custom code
&lt;/h3&gt;

&lt;p&gt;Review your own modules and the offenders from a &lt;a href="https://magevanta.com/blog/magento-2-third-party-extension-performance-audit" rel="noopener noreferrer"&gt;third-party extension performance audit&lt;/a&gt;: replace "static array as cache" with a real cache backend, and never store loaded entities in static properties across iterations. If you genuinely need to reference an object without keeping it alive, PHP 7.4+ &lt;code&gt;WeakReference&lt;/code&gt; is the correct tool — but the correct answer is usually "don't retain it at all".&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Prefer more, shorter workers over one immortal worker
&lt;/h3&gt;

&lt;p&gt;A consumer that runs 24/7 with 2 GB of retained memory is a crash waiting to happen. Five consumers with &lt;code&gt;--max-messages=200&lt;/code&gt; under Supervisor process the same volume, spread risk, and absorb a crash without a backlog. The same logic applies to cron: split heavy jobs into smaller scheduled runs instead of one monolithic window (see the &lt;a href="https://magevanta.com/blog/magento-2-cron-optimization" rel="noopener noreferrer"&gt;cron optimization guide&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Monitor memory as a first-class metric
&lt;/h3&gt;

&lt;p&gt;Sample RSS per process with Prometheus + node_exporter (or your APM), and alert on &lt;em&gt;trend&lt;/em&gt; over 1–2 hours, not on absolute thresholds. If a consumer's RSS climbs steadily, that's a leak signal long before it OOMs. Keep an eye on the number of consumers vs &lt;code&gt;memory_limit&lt;/code&gt; on the box: with several consumers at 1 GB each, a 4 GB server dies from memory pressure even though no single process crashed.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Don't just raise memory_limit
&lt;/h3&gt;

&lt;p&gt;Raising &lt;code&gt;memory_limit&lt;/code&gt; converts a 30-minute crash into a 3-hour one, and pushes the problem into the kernel: under sustained pressure the OOM killer may take down MySQL or Redis along with your consumers. Fix the leak or bound the process — the limit is a safety net, not a solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the fix
&lt;/h2&gt;

&lt;p&gt;Run the consumer on a baseline, sample RSS every few minutes, and compare before/after:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Before: RSS climbs ~25 MB per 1,000 messages and never comes back down.&lt;/li&gt;
&lt;li&gt;After: RSS plateaus or sawtooths within a ±50 MB band over 20,000 messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the trend stays flat over a full day and a bounded restart cycle, the leak is contained. Log &lt;code&gt;memory_get_peak_usage(true)&lt;/code&gt; at the end of every cron job so a regression shows up in your &lt;a href="https://magevanta.com/blog/magento-2-logging-best-practices" rel="noopener noreferrer"&gt;logging best practices&lt;/a&gt; trail before it shows up in an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every queue consumer started with &lt;code&gt;--max-messages&lt;/code&gt; (or &lt;code&gt;--max-execution-time&lt;/code&gt;) and under a respawning supervisor.&lt;/li&gt;
&lt;li&gt;Cron jobs batch-load collections and call &lt;code&gt;-&amp;gt;clear()&lt;/code&gt; per batch.&lt;/li&gt;
&lt;li&gt;No static arrays holding entities across loop iterations; no &lt;code&gt;file()&lt;/code&gt; on huge CSVs; streaming imports instead.&lt;/li&gt;
&lt;li&gt;Profiler disabled in production.&lt;/li&gt;
&lt;li&gt;Custom long loops call &lt;code&gt;gc_collect_cycles()&lt;/code&gt; periodically.&lt;/li&gt;
&lt;li&gt;RSS trend monitored and alerted, not just peak memory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;memory_limit&lt;/code&gt; treated as a safety net, not a fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Memory leaks in cron and queue consumers are an operational problem, not a mystery: measure the trend, bound the process, batch the work, and restart on a schedule. Do that, and the 2 AM OOM becomes a thing of the past — and your &lt;a href="https://magevanta.com/blog/magento-2-async-operations-message-queues" rel="noopener noreferrer"&gt;message queues&lt;/a&gt; and indexers keep running while you sleep.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Customer Login &amp; Authentication Performance: The Slow Login Bottleneck</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:01:09 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-customer-login-authentication-performance-the-slow-login-bottleneck-591c</link>
      <guid>https://dev.to/magevanta/magento-2-customer-login-authentication-performance-the-slow-login-bottleneck-591c</guid>
      <description>&lt;p&gt;A slow login is one of the most damaging performance problems a store can have. Unlike a slow category page, which costs you a view, a slow login sits directly between a returning customer and their wallet. Every extra second on the authentication path quietly raises cart abandonment and pushes people toward guest checkout or a competitor.&lt;/p&gt;

&lt;p&gt;The frustration is that login slowness rarely shows up in your standard page-speed metrics. A login POST is a form submission, not a render — so Core Web Vitals tells you nothing. You have to measure the request itself and understand the work Magento does between "submit" and "redirect to account". This guide breaks down every step and what you can safely optimize.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens during a login request
&lt;/h2&gt;

&lt;p&gt;When a customer submits the login form, Magento runs a surprising amount of work in a single request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The POST hits the front controller and session cookie handling.&lt;/li&gt;
&lt;li&gt;The account controller loads the customer via &lt;code&gt;CustomerRepositoryInterface&lt;/code&gt; and verifies the password.&lt;/li&gt;
&lt;li&gt;Magento validates the password hash with PHP's &lt;code&gt;password_verify&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It reloads the full customer object and all dependent data (addresses, groups, default billing/shipping, tax + exchange-rate data).&lt;/li&gt;
&lt;li&gt;Customer data sections are refreshed so the quote, customer and cart sections repopulate.&lt;/li&gt;
&lt;li&gt;The session is persisted (or the token exchanged if you use a custom auth flow).&lt;/li&gt;
&lt;li&gt;The account dashboard or redirect target renders.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each step has its own cost, and most of them multiply.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real cost of password hashing
&lt;/h2&gt;

&lt;p&gt;Magento 2 stores customer passwords with PHP's &lt;code&gt;password_hash&lt;/code&gt;/&lt;code&gt;password_verify&lt;/code&gt; using bcrypt. This is the right call for security, but bcrypt is deliberately expensive — it is designed to slow down brute-force attacks. The default cost factor in Magento's &lt;code&gt;Security.xml&lt;/code&gt; is &lt;strong&gt;10&lt;/strong&gt;, meaning roughly 2^10 iterations of the key-derivation rounds.&lt;/p&gt;

&lt;p&gt;A bcrypt verify at cost 10 typically takes &lt;strong&gt;50–150 ms&lt;/strong&gt; of pure CPU on a modern server, and far more on constrained or shared hosting. That doesn't sound like much until you add it to everything else.&lt;/p&gt;

&lt;p&gt;The trap people fall into is cranking the cost factor up "for security." Every increase in cost roughly doubles the time. Going from cost 10 to 12 can push a single verify past 200–400 ms of CPU — on every login, for every customer, multiplied by your login rate. If you also run brute-force two-factor flows or admin logins through the same hashing, the cost stacks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify the cost, don't guess
&lt;/h3&gt;

&lt;p&gt;You can check the configured cost factor in &lt;code&gt;app/etc/env.php&lt;/code&gt; or the &lt;code&gt;Security&lt;/code&gt; config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$customer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPasswordHash&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nb"&gt;password_get_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$hash&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="s1"&gt;'options'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'cost'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the cost is 12 or higher and logins feel heavy, &lt;strong&gt;do not lower it blindly&lt;/strong&gt; — lower cost directly weakens security. Instead, understand whether you actually need that high a factor. NIST and most providers consider 10–11 acceptable for customer-facing bcrypt in 2026; anything above that gives marginal security gain against password-cracking hardware already hitting diminishing returns, while costing you real latency per login.&lt;/p&gt;

&lt;p&gt;There is a legitimate middle path: keep an adequate cost factor and move authentication off the main web thread via a queued or sub-request hashing flow. Magento's own &lt;code&gt;CustomerAuthenticationInterface&lt;/code&gt; can be wrapped so verification runs asynchronously, or behind a pre-auth gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 problem in "load customer for login"
&lt;/h2&gt;

&lt;p&gt;The biggest silent killer is how the customer is loaded after verification. &lt;code&gt;CustomerRepositoryInterface::getById&lt;/code&gt; looks clean, but underneath it triggers attribute loading (EAV), plus selection of the default shipping address, billing address, and the customer group — often as separate queries. On a store with many attributes or a bloated &lt;code&gt;customer_entity&lt;/code&gt; EAV layout, the post-login load can fire &lt;strong&gt;dozens&lt;/strong&gt; of queries.&lt;/p&gt;

&lt;p&gt;Compounding it: tax and exchange-rate data are resolved per customer group on every login, and the account page then queries order history, wishlist counts and newsletter state. None of this is cached.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to do
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Profile first&lt;/strong&gt;: enable the built-in profiler or log query counts around the login request. If you see more than ~20 queries in &lt;code&gt;CustomerRepository::getById&lt;/code&gt; territory, you have a real EAV problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim customer attributes&lt;/strong&gt;: move rarely-used custom customer attributes that aren't indexed for login out of the direct-load path. Every extra attribute added to the customer form adds EAV rows and join cost on every load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch the dependent data&lt;/strong&gt;: wrap the default address, group and tax lookups in a single &lt;code&gt;getList&lt;/code&gt; with appropriate attribute sets instead of separate repository calls.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache what's stable&lt;/strong&gt;: customer group, default address and tax-class data change rarely. A short-lived cache or layered cache handler around those lookups removes the per-login query spike without risking stale pricing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Session persistence: the silent second bottleneck
&lt;/h2&gt;

&lt;p&gt;After auth, Magento persists the customer session. Where you store sessions already got a full write-up elsewhere on this site (Redis over files, every time). But login adds extra session weight: the auth flow writes the customer ID, form keys, the cart contents reference, and section data. If your session store is disk-backed or your single Redis node is undersized, that write lands smack in the middle of the login request.&lt;/p&gt;

&lt;p&gt;Check your session store is also scanned on &lt;strong&gt;read&lt;/strong&gt;: the first thing login does is read the existing guest session to merge the cart. A slow session read (or a full-GC pass scanning disk) makes every login crawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  Customer data sections get refreshed on login
&lt;/h2&gt;

&lt;p&gt;When a customer logs in, the frontend reloads all customer data sections (&lt;code&gt;customer&lt;/code&gt;, &lt;code&gt;cart&lt;/code&gt;, &lt;code&gt;messages&lt;/code&gt;, and any custom section you registered) via &lt;code&gt;customerData.reload()&lt;/code&gt;. Each section is a separate AJAX request hitting the account controller endpoints. If you see a burst of parallel &lt;code&gt;/customer/section/load&lt;/code&gt; calls after the redirect, that's normal — but it's also fresh DB/Redis work that can be trimmed by disabling sections you don't use (see the customer-data-sections strategy on this site).&lt;/p&gt;

&lt;h2&gt;
  
  
  Bruteforce protection &amp;amp; external auth — measure it
&lt;/h2&gt;

&lt;p&gt;Magento 2.4's brute-force protection (login attempt limits and CAPTCHA/reCAPTCHA) is good, but the throttling lookup and reCAPTCHA verification add latency. If you use &lt;code&gt;Magento\LoginAsCustomer&lt;/code&gt;-style proxying, SAML, or LDAP auth, each login now depends on an &lt;strong&gt;external round-trip&lt;/strong&gt; — often 200–600 ms of network time on top of everything above. &lt;/p&gt;

&lt;p&gt;For external auth, add connection pooling and keep the identity provider endpoint hot (a cold IdP call can add a full second). At minimum, put a caching layer on the session-bound auth token so repeated requests within a session don't re-hit the IdP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The optimization playbook
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure the login endpoint directly&lt;/strong&gt; — cap a POST login in Browser DevTools network tab or &lt;code&gt;curl -w "%{time_total}"&lt;/code&gt; with a valid credential. Get a before number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable the profiler&lt;/strong&gt; and count queries for one login. Fix any EAV/dependent-data N+1 first — this is usually the biggest win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the bcrypt cost factor&lt;/strong&gt; and confirm it's 10–11, not inflated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move sessions to Redis&lt;/strong&gt; (or upgrade the node) and confirm the store is healthy on read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim customer data sections&lt;/strong&gt; that reload on login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pool and cache external auth&lt;/strong&gt; if you use SSO/LDAP/SAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the login path to your performance regression budget&lt;/strong&gt; (Lighthouse CI or a curl TTFB gate) so a future plugin can't silently re-slow it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  When to leave it alone
&lt;/h2&gt;

&lt;p&gt;Don't shave security to hit a number. Keep the bcrypt factor adequate, keep brute-force throttling on, and don't cache anything customer-specific or price-sensitive. The goal is to remove &lt;strong&gt;wasted&lt;/strong&gt; work — duplicate queries, avoidable EAV joins, cold session stores, redundant external calls — not to weaken the auth itself. Do that, and a login that once took 1.5 s of accumulated work can drop to a few hundred milliseconds of necessary work, which is exactly what a returning customer should feel.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Performance TPU: profile the login POST end-to-end, then attack the N+1 and session-store costs before ever touching the hash cost.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Sitemap Generation Performance: Why It Spikes CPU, Memory and MySQL</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Sat, 05 Sep 2026 09:02:00 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-sitemap-generation-performance-why-it-spikes-cpu-memory-and-mysql-147</link>
      <guid>https://dev.to/magevanta/magento-2-sitemap-generation-performance-why-it-spikes-cpu-memory-and-mysql-147</guid>
      <description>&lt;p&gt;Sitemap generation is one of those background jobs everyone ignores — until the sitemap cron starts pegging CPU and memory on a large catalog at midnight, collides with the price indexer, and makes the store crawl. The XML sitemap is tiny compared to your catalog, but the process that builds it walks your &lt;em&gt;entire&lt;/em&gt; product, category and CMS content, so its cost scales linearly with catalog size. This guide explains exactly how Magento builds the sitemap, where the hidden cost comes from, and how to make generation fast, batched and off-peak.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Magento builds the sitemap
&lt;/h2&gt;

&lt;p&gt;Behind the scenes, Magento runs the &lt;code&gt;sitemap_generate&lt;/code&gt; cron job, which is registered in &lt;code&gt;Magento\Sitemap\etc\crontab.xml&lt;/code&gt; at the default schedule of &lt;code&gt;0 0 2 1 *&lt;/code&gt; — i.e. at 2 AM on the first of January by default. Most production setups override this in &lt;code&gt;env.php&lt;/code&gt; or the admin (&lt;code&gt;Stores &amp;gt; Configuration &amp;gt; Catalog &amp;gt; XML Sitemap&lt;/code&gt;) to run nightly or weekly. When it fires, the &lt;code&gt;Sitemap&lt;/code&gt; model calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Sitemap::generateXml()&lt;/code&gt; which collects &lt;strong&gt;products&lt;/strong&gt;, &lt;strong&gt;categories&lt;/strong&gt; and &lt;strong&gt;CMS pages&lt;/strong&gt; per configured store view and renders each entry as a &lt;code&gt;&amp;lt;url&amp;gt;&lt;/code&gt; node&lt;/li&gt;
&lt;li&gt;It resolves each entity's final URL via the URL rewrite table (&lt;code&gt;url_rewrite&lt;/code&gt;), computes priority and change frequency from admin config, and optionally appends image URLs configured on products (&lt;code&gt;images&lt;/code&gt; under sitemap settings)&lt;/li&gt;
&lt;li&gt;It writes the resulting XML to &lt;code&gt;pub/media/sitemap/sitemap.xml&lt;/code&gt; (one file per store view)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The observable cost looks cheap: a few seconds, a few megabytes of XML. The real cost is internal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the hidden cost comes from
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Whole-catalog iteration on every run
&lt;/h3&gt;

&lt;p&gt;Sitemap generation is not incremental. Every scheduled run walks every product, every category and every CMS page again, even if nothing changed. On a 100k-SKU catalog that is tens of thousands of entities each run, regardless of whether a single page changed since the last sitemap.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Per-entity URL and attribute resolution
&lt;/h3&gt;

&lt;p&gt;Each product entry needs its final &lt;code&gt;store_id&lt;/code&gt;-scoped URL, so the generator joins against &lt;code&gt;url_rewrite&lt;/code&gt; and reads core EAV attributes for the entity. Naive versions of this pattern perform several queries per entity — a classic N+1. Newer Magento releases (2.4.x, especially 2.4.7+) batch entity resolution via &lt;code&gt;SitemapItemResolver&lt;/code&gt; and &lt;code&gt;getCollection()&lt;/code&gt; with chunked iteration, which is dramatically cheaper than the older per-entity resource models. If you are on 2.4.6 or older, part of your sitemap slowness is simply the un-batched resolver.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Whole-XML-in-memory
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;generateXml()&lt;/code&gt; builds the complete XML document in memory before writing it to disk. On a very large catalog the sitemap can reach tens of megabytes, so peak memory on the CLI worker spikes proportionally to catalog size. And because this runs on the same &lt;code&gt;cron&lt;/code&gt; consumer pool, a memory-hungry sitemap run can tip a PHP worker over &lt;code&gt;memory_limit&lt;/code&gt; and abort mid-generation, leaving a truncated sitemap on disk.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. MySQL load on a full-table scan
&lt;/h3&gt;

&lt;p&gt;Reading every product and rewriting it joins the catalog tables, &lt;code&gt;url_rewrite&lt;/code&gt;, and the media gallery. On stores with heavy &lt;code&gt;url_rewrite&lt;/code&gt; tables (the &lt;a href="https://magevanta.com/blog/magento-2-url-rewrite-performance" rel="noopener noreferrer"&gt;URL rewrite performance&lt;/a&gt; area), the sitemap cron can trigger a costly scan right when your nightly &lt;a href="https://magevanta.com/blog/magento-2-cron-optimization" rel="noopener noreferrer"&gt;cron optimization&lt;/a&gt; and &lt;a href="https://magevanta.com/blog/magento-2-indexer-optimization" rel="noopener noreferrer"&gt;indexer jobs&lt;/a&gt; are also running — three heavy jobs competing for the same pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosing a slow sitemap run
&lt;/h2&gt;

&lt;p&gt;Get a baseline before changing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Time a single generation run&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;bin/magento sitemap:generate

&lt;span class="c"&gt;# Watch real peak memory (set -d for the run only, keep the pool default)&lt;/span&gt;
/usr/bin/time &lt;span class="nt"&gt;-v&lt;/span&gt; php &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nv"&gt;memory_limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2G bin/magento sitemap:generate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The size and line count of &lt;code&gt;pub/media/sitemap/sitemap.xml&lt;/code&gt; — compare across stores&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sitemap.log&lt;/code&gt; under &lt;code&gt;var/log/&lt;/code&gt; — by default Magento logs generation progress&lt;/li&gt;
&lt;li&gt;The MySQL query time during the run (&lt;code&gt;SHOW FULL PROCESSLIST&lt;/code&gt; or your slow-query log) to catch full scans on &lt;code&gt;url_rewrite&lt;/code&gt; or &lt;code&gt;catalog_product_entity&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Whether the run is killed or times out — that is a truncated sitemap, not a completed one&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the run is crash-safe, quick, but still late at night colliding with other jobs, the fix is scheduling, not code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The optimization playbook
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Batch: upgrade your resolver
&lt;/h3&gt;

&lt;p&gt;If you are on Magento 2.4.5/2.4.6, the single cheapest win is upgrading to a release with the batched &lt;code&gt;SitemapItemResolver&lt;/code&gt;. It replaces per-entity queries with chunked collection iteration and cuts both runtime and query count by an order of magnitude on large catalogs. Measure before and after — this is the highest-ROI step.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Offset generation per store
&lt;/h3&gt;

&lt;p&gt;The sitemap generates one file per store view. If you run multiple store views (see &lt;a href="https://magevanta.com/blog/magento-2-multistore-performance" rel="noopener noreferrer"&gt;multistore performance&lt;/a&gt;), spread them instead of generating all simultaneously. You can trigger per-store generation programmatically with &lt;code&gt;--store&lt;/code&gt; on &lt;code&gt;bin/magento sitemap:generate&lt;/code&gt;, then schedule each store on a different time slot so no single cron window runs all stores at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Move it off the main cron window
&lt;/h3&gt;

&lt;p&gt;Sitemap does not need to run alongside the price indexer or full reindex. Move it to a low-traffic, low-contention window — ideally a separate schedule from the &lt;a href="https://magevanta.com/blog/magento-2-cron-optimization" rel="noopener noreferrer"&gt;admin cron group&lt;/a&gt; that runs indexers. A dedicated cron schedule (&lt;code&gt;config:crontab:set wget "https://..."&lt;/code&gt; or a separate consumer) keeps sitemap out of the main cron pileup.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Shrink what gets included
&lt;/h3&gt;

&lt;p&gt;Fewer entries means a smaller file and less work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Disable sitemap for store views you do not need (&lt;code&gt;Stores &amp;gt; Configuration &amp;gt; Catalog &amp;gt; XML Sitemap &amp;gt; Enabled&lt;/code&gt;, per scope)&lt;/li&gt;
&lt;li&gt;Exclude entities that should not be indexed&lt;/li&gt;
&lt;li&gt;If product image URLs are enabled, they multiply XML size and add media-gallery joins — disable them unless your SEO setup needs rich results&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;url_rewrite&lt;/code&gt; clean and pruned; the sitemap reads it per entity, so orphaned rewrites make every scan slower (see the &lt;a href="https://magevanta.com/blog/magento-2-url-rewrite-performance" rel="noopener noreferrer"&gt;URL rewrite deep-dive&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Stream instead of buffer (large catalogs only)
&lt;/h3&gt;

&lt;p&gt;If you are on an older version and a very large catalog, the in-memory &lt;code&gt;generateXml()&lt;/code&gt; is the bottleneck. Replace the default model with a custom generator that iterates the collection in chunks and writes directly to a temp file, then renames it into place at the end. Chunked file writes keep peak memory flat and guarantee the live sitemap is never a truncated one. This is a small custom module and is the correct fix rather than raising &lt;code&gt;memory_limit&lt;/code&gt; on the pool.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Serve it smartly
&lt;/h3&gt;

&lt;p&gt;Share-cached sitemaps are served as static files from &lt;code&gt;pub/media&lt;/code&gt;. Make sure the path is served by your web server directly (not routed through PHP-FPM) so the CDN and Varnish cache it, and confirm &lt;code&gt;robots.txt&lt;/code&gt; points at the correct store-scoped file. If you route the sitemap through an HTTP handler, you can also delegate generation to a lightweight background request instead of the cron worker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring
&lt;/h2&gt;

&lt;p&gt;Add sitemap generation to your &lt;a href="https://magevanta.com/blog/magento-2-automated-performance-regression-testing" rel="noopener noreferrer"&gt;automated regression testing&lt;/a&gt; budget: assert that &lt;code&gt;bin/magento sitemap:generate&lt;/code&gt; on your staging catalog (or a sized-down clone) completes under a budgeted wall-clock time and stays under a memory ceiling. A regression — e.g. a third-party module that hooks sitemap generation and triples its runtime — should fail CI, not silently slow your nightly job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Sitemap generation looks trivial but walks your whole catalog on every run. The three levers that matter: use a batched resolver (upgrade for large catalogs), schedule it off-peak and per-store so it never collides with indexers, and shrink or stream the output so memory stays flat. With that, a midnight sitemap cron is a blip, not a spike.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Frontend Performance: Web Fonts, Critical CSS and Render-Blocking Resources</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Fri, 04 Sep 2026 09:01:28 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-frontend-performance-web-fonts-critical-css-and-render-blocking-resources-4c7c</link>
      <guid>https://dev.to/magevanta/magento-2-frontend-performance-web-fonts-critical-css-and-render-blocking-resources-4c7c</guid>
      <description>&lt;p&gt;A Magento 2 frontend can have excellent backend TTFB and still feel slow, because the browser spends its first seconds downloading and blocking on stylesheets and fonts before it can paint a stable page. Web fonts and render-blocking CSS are where the &lt;a href="https://magevanta.com/blog/magento-2-core-web-vitals-guide" rel="noopener noreferrer"&gt;Core Web Vitals&lt;/a&gt; metrics you spent weeks on — especially Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS) and First Contentful Paint (FCP) — get silently undermined. This guide walks through how a Magento theme loads these resources by default and how to reclaim that frontend time methodically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why fonts and CSS dominate the critical path
&lt;/h2&gt;

&lt;p&gt;For an anonymous visitor hitting a default Luma-based or Hyva-style theme, the browser must fetch, parse and apply the full stylesheet before it can render. Even with aggressive JavaScript bundling, an unoptimized theme ships one large &lt;code&gt;styles.css&lt;/code&gt; plus a handful of web font files — several hundred kilobytes of blocking work before first paint. Fonts add a second tax: browsers default to &lt;code&gt;font-display: auto&lt;/code&gt;, which hides text while a web font downloads (FOIT, flash of invisible text) and shifts layout when it eventually swaps in.&lt;/p&gt;

&lt;p&gt;The critical rendering path for a typical page is: HTML → CSSOM (blocking stylesheets) → layout → paint. Every byte of CSS on this path delays FCP; every web font that isn't preloaded delays the visible headline, which is often the LCP element. The fix is not "don't use fonts" — it is controlling &lt;em&gt;when&lt;/em&gt; each resource loads and &lt;em&gt;how much&lt;/em&gt; ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Magento loads frontend resources
&lt;/h2&gt;

&lt;p&gt;Understand the two rails, because they behave differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CSS&lt;/strong&gt; is usually combined and deployed by &lt;code&gt;setup:static-content:deploy&lt;/code&gt; and linked in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; by &lt;code&gt;Magento_Theme&lt;/code&gt;. Magento emits it via &lt;code&gt;Magento\Framework\View\Asset\Repository&lt;/code&gt;, and themes override layout handles (&lt;code&gt;default_head_blocks&lt;/code&gt;) to inject stylesheets. Because they are plain &lt;code&gt;&amp;lt;link rel="stylesheet"&amp;gt;&lt;/code&gt; tags, every one of them blocks render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web fonts&lt;/strong&gt; are loaded either from the theme's &lt;code&gt;fonts&lt;/code&gt; folder, from a CDN like Google Fonts / Font Awesome, or — in Magento UI / older themes — via &lt;code&gt;Magento\Theme\Block\Html\Head&lt;/code&gt; with &lt;code&gt;css/&lt;/code&gt; resources. The classic Luma setup links a &lt;code&gt;fonts.css&lt;/code&gt; in the head, which pulls in the actual &lt;code&gt;@font-face&lt;/code&gt; files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first audit is simply to view-source your rendered homepage and count &lt;code&gt;&amp;lt;link rel="stylesheet"&amp;gt;&lt;/code&gt; entries and font requests, then use the Performance panel's blocking list in DevTools. Stores frequently discover 8–20 stylesheets and 6–12 font files they believed were merged or subsetted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical levers, in order of payoff
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Cut the payload before you optimize delivery
&lt;/h3&gt;

&lt;p&gt;The cheapest optimization is shipping less. If a theme imports multiple font families as full weights and styles, prune to what actually appears in your templates. Each family at 2–3 weights with latin subset typically costs 30–80 KB per file. Remove unused Google Fonts families and Font Awesome glyph packs you load wholesale; replace them with only the icons you render.&lt;/p&gt;

&lt;p&gt;For Magento-deployed assets, keep the merged stylesheet coherent: &lt;code&gt;setup:static-content:deploy&lt;/code&gt; with &lt;code&gt;-j&lt;/code&gt; parallelism and the optimized strategy (see the &lt;a href="https://magevanta.com/blog/magento-2-static-content-deploy-optimization" rel="noopener noreferrer"&gt;static content deploy guide&lt;/a&gt;) shortens build, but the browser still downloads one combined CSS. Splitting a megabyte block into a small "critical" file and a deferred "rest" file is the classic critical-CSS play.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Critical CSS + deferring the rest
&lt;/h3&gt;

&lt;p&gt;Extract the styles that govern above-the-fold content (header, hero, first product tiles) into a small inline &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, then load the full stylesheet asynchronously or after load. Two ways to load the non-critical sheet without blocking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- load after paint --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;".../styles.css"&lt;/span&gt; &lt;span class="na"&gt;media=&lt;/span&gt;&lt;span class="s"&gt;"print"&lt;/span&gt; &lt;span class="na"&gt;onload=&lt;/span&gt;&lt;span class="s"&gt;"this.media='all'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- or --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;".../styles.css"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"style"&lt;/span&gt; &lt;span class="na"&gt;onload=&lt;/span&gt;&lt;span class="s"&gt;"this.rel='stylesheet'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pair this with a &lt;code&gt;noscript&lt;/code&gt; fallback. In a Magento theme, inject the critical CSS through a child theme's &lt;code&gt;Magento_Theme/layout/default_head_blocks.xml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;referenceBlock&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"head.additional"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;block&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"Magento\Framework\View\Element\Template"&lt;/span&gt;
           &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"critical.css"&lt;/span&gt;
           &lt;span class="na"&gt;template=&lt;/span&gt;&lt;span class="s"&gt;"Magento_Theme::critical-css.phtml"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/referenceBlock&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where that template &lt;code&gt;&amp;lt;?= $criticalCss ?&amp;gt;&lt;/code&gt; is the extracted above-the-fold styles. Generating the extract is a build step — tools like &lt;code&gt;critical&lt;/code&gt;, or &lt;code&gt;penthouse&lt;/code&gt; for full HTML pages, produce the inlined block; run it in CI after &lt;code&gt;static:content:deploy&lt;/code&gt; so it tracks theme changes rather than drifting.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Font loading: subset, format, preload, and display
&lt;/h3&gt;

&lt;p&gt;Web fonts have four knobs. Set all of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;font-display&lt;/code&gt;&lt;/strong&gt;: use &lt;code&gt;swap&lt;/code&gt; (or &lt;code&gt;optional&lt;/code&gt; for icon-ish UI fonts) so text renders in a fallback instantly and swaps when ready. This removes FOIT and most of the font-related CLS. In your &lt;code&gt;@font-face&lt;/code&gt; blocks, this is one line per face; Google Fonts lets you append &lt;code&gt;&amp;amp;display=swap&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subset&lt;/strong&gt;: serve only the character ranges a store actually uses. Tools like &lt;code&gt;glyphhanger&lt;/code&gt; or Font Squirrel's subsetter slice the &lt;code&gt;.woff2&lt;/code&gt; to Latin (and any accented locales) — often cutting file size 60–80%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format&lt;/strong&gt;: ship only &lt;code&gt;woff2&lt;/code&gt; (universally supported now) instead of carrying woff/ttf/eot fallbacks. Each extra format is just extra bytes the browser ignores.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preload the hero font&lt;/strong&gt;: if the headline font renders the LCP text, &lt;code&gt;&amp;lt;link rel="preload" href="/fonts/headline.woff2" as="font" type="font/woff2" crossorigin&amp;gt;&lt;/code&gt; lets the browser start that download during HTML parse instead of only after it hits the stylesheet. This is frequently the difference between a 0.8s and a 1.6s LCP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A Magento child theme declares fonts via the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; block. For the standard &lt;code&gt;@font-face&lt;/code&gt; loading, overriding the theme's &lt;code&gt;fonts.css&lt;/code&gt; with a leaner, &lt;code&gt;woff2&lt;/code&gt;-only, subsetted version is the fastest route, then preload the single face that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;referenceBlock&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"head.additional"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;block&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"Magento\Framework\View\Element\Template"&lt;/span&gt;
           &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"preload.font"&lt;/span&gt; &lt;span class="na"&gt;template=&lt;/span&gt;&lt;span class="s"&gt;"Magento_Theme::preload-font.phtml"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;arguments&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;argument&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"fontUrl"&lt;/span&gt; &lt;span class="na"&gt;xsi:type=&lt;/span&gt;&lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;/fonts/headline.woff2&lt;span class="nt"&gt;&amp;lt;/argument&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/arguments&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/block&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/referenceBlock&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Resource hints and eliminating round trips
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;preconnect&lt;/code&gt;&lt;/strong&gt;: for Google Fonts / CDNs, &lt;code&gt;&amp;lt;link rel="preconnect" href="https://fonts.gstatic.com" crossorigin&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;link rel="preconnect" href="https://cdn.example.com"&amp;gt;&lt;/code&gt; tear down the connection early, shaving 1–2 RTTs off those requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;preload as="style"&lt;/code&gt;&lt;/strong&gt; for the single most important stylesheet or image so it joins the early download.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;defer&lt;/code&gt;/&lt;code&gt;async&lt;/code&gt; on non-critical JavaScript. A &lt;code&gt;dataLayer&lt;/code&gt; or analytics script without &lt;code&gt;defer&lt;/code&gt; delays interactive; the &lt;a href="https://magevanta.com/blog/magento-2-javascript-bundling-requirejs-optimization" rel="noopener noreferrer"&gt;JavaScript bundling and RequireJS guide&lt;/a&gt; covers that side in depth.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Measuring the result
&lt;/h2&gt;

&lt;p&gt;Before/after matters more than any rule of thumb. Use Lighthouse on a representative product page, and capture FCP, LCP, CLS, and total blocking time. Then confirm in the DevTools waterfall that the critical CSS is inlined, the main stylesheet is non-blocking, and the hero font request starts preload-time and arrives before first paint. Validate the font swap with a throttled 4G profile: text should be legible (fallback) immediately, not blank.&lt;/p&gt;

&lt;p&gt;For automating this as a guard rail, fold FCP/LCP/CLS budgets into your pipeline — the &lt;a href="https://magevanta.com/blog/magento-2-automated-performance-regression-testing" rel="noopener noreferrer"&gt;automated performance regression testing guide&lt;/a&gt; shows how to make these thresholds fail CI rather than silently regress.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sane baseline checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Few, merged, &lt;code&gt;woff2&lt;/code&gt;-only web fonts; unused families and glyph packs removed&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;font-display: swap&lt;/code&gt; (or &lt;code&gt;optional&lt;/code&gt;) on every &lt;code&gt;@font-face&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Hero LCP font preloaded with &lt;code&gt;crossorigin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Critical CSS inlined; full stylesheet loaded non-blocking with noscript fallback&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;preconnect&lt;/code&gt; to CDN/font origins&lt;/li&gt;
&lt;li&gt;[ ] Non-critical JS &lt;code&gt;defer&lt;/code&gt;/&lt;code&gt;async&lt;/code&gt;; no render-blocking third-party scripts in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] FCP/LCP/CLS measured before and after, ideally in CI&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to skip this
&lt;/h2&gt;

&lt;p&gt;If a store is still serving a slow, un-merged stylesheet or lacks HTTP/2 entirely, fix those first — see the &lt;a href="https://magevanta.com/blog/magento-2-cdn-configuration-guide" rel="noopener noreferrer"&gt;CDN configuration guide&lt;/a&gt; and the &lt;a href="https://magevanta.com/blog/magento-2-nginx-optimization-high-traffic" rel="noopener noreferrer"&gt;nginx optimization guide&lt;/a&gt;. Critical CSS and font preload are refinements that amplify a solid delivery pipeline; applied to a store with 1.5s of blocking CSS, they still help, but they are not a substitute for correct caching and a working reverse proxy.&lt;/p&gt;

&lt;p&gt;Done right, web fonts and critical CSS turn a store that "paints late and shifts when fonts land" into one whose hero image and headline render in the first paint and stay put — which is exactly what LCP and CLS are asking for.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Magento 2 Time-to-First-Byte: Diagnosing and Cutting TTFB on Slow Stores</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Thu, 03 Sep 2026 09:02:08 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-time-to-first-byte-diagnosing-and-cutting-ttfb-on-slow-stores-11ib</link>
      <guid>https://dev.to/magevanta/magento-2-time-to-first-byte-diagnosing-and-cutting-ttfb-on-slow-stores-11ib</guid>
      <description>&lt;p&gt;Time-to-First-Byte (TTFB) is the time between a browser sending a request and receiving the first byte of the response. In Magento 2 stores it is routinely the single biggest component of perceived slowness — and the part developers most often misread as a "network" problem. This is a measured, layer-by-layer guide to finding where your TTFB actually goes and reducing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why TTFB matters more than you think
&lt;/h2&gt;

&lt;p&gt;Lighthouse and Core Web Vitals shine a spotlight on Largest Contentful Paint (LCP), but LCP can rarely beat TTFB by much. A 1.2-second TTFB on a product page caps your LCP regardless of how aggressively you bundle JavaScript or serve compressed images. Google has measured that a 100ms reduction in TTFB reliably improves conversion, so it is worth treating TTFB as a first-class metric, not a side effect.&lt;/p&gt;

&lt;p&gt;Before optimizing, log TTFB accurately. The Chrome DevTools Networking panel reports it, but for repeatable numbers use a scripted check that curls the page across several runs, warm and cold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4 5&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"TTFB: %{time_starttransfer}s | Total: %{time_total}s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    https://example.com/product.html
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gap between TTFB and total time is download time (dominated by payload size, static assets, and compression). The gap between connection time and TTFB is server-side latency — that is what this article tackles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Break TTFB into its layers
&lt;/h2&gt;

&lt;p&gt;TTFB decomposes roughly as: DNS → TLS handshake → reverse-proxy accept → PHP-FPM queue/wait → PHP frame (router, bootstrap, layout, FPC/BFC hit) → database/Redis/Elasticsearch calls → first byte out.&lt;/p&gt;

&lt;p&gt;Profiler output is the fastest way to see the split. The built-in Magento profiler (&lt;code&gt;Mage::setIsDeveloperMode(true)&lt;/code&gt; plus &lt;code&gt;dev/debug/profiler&lt;/code&gt; in the DB) prints a flat/grouped profile to the page. For production, an APM like New Relic or Blackfire shows the same breakdown without giving customers profiler output. If TTFB is high but the PHP profile is clean and fast, your latency is upstream — TLS, proxy, or queueing.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. TLS and connection setup
&lt;/h3&gt;

&lt;p&gt;Each HTTPS request pays for a TLS handshake. Two quick wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enable TLS 1.3 and session resumption.&lt;/strong&gt; TLS 1.3 cuts the handshake to one round trip and, with session tickets, allows resumption on subsequent connections. Make sure &lt;code&gt;session_tickets&lt;/code&gt; and OCSP stapling are enabled at Nginx level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep connections alive.&lt;/strong&gt; &lt;code&gt;keepalive 64;&lt;/code&gt; in the upstream block and HTTP/2 both let one browser connection serve many requests, amortizing handshakes across JS, CSS and image requests. A store that does this can drop its median TTFB noticeably on repeat visits without touching PHP at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Reverse proxy and PHP-FPM queueing
&lt;/h3&gt;

&lt;p&gt;A very common failure is not a slow PHP process but PHP-FPM having no free worker to serve the request. Check the queue:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;watch &lt;span class="nt"&gt;-n&lt;/span&gt; 2 &lt;span class="s2"&gt;"pgrep -c php-fpm"&lt;/span&gt;
&lt;span class="c"&gt;# and inspect the Nginx error log for "max_children reached"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see the &lt;code&gt;slow log&lt;/code&gt; filling (configure &lt;code&gt;request_slowlog_timeout&lt;/code&gt; and &lt;code&gt;slowlog&lt;/code&gt; in &lt;code&gt;php-fpm.conf&lt;/code&gt;), workers are busy long past their budget. The remedy is usually &lt;code&gt;pm.max_children&lt;/code&gt; too low for the traffic mix, or a handful of pathological requests monopolizing workers. Raising &lt;code&gt;max_children&lt;/code&gt; without checking memory (&lt;code&gt;pm&lt;/code&gt; is dynamic, each child consumes its &lt;code&gt;memory_limit&lt;/code&gt;) can swap instead of speeding up.&lt;/p&gt;

&lt;p&gt;Two structural fixes for queue health:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Health checks and drain.&lt;/strong&gt; Route health-checked traffic to Nginx upstreams so dead or saturated FPM pools don't get new requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microcache or Varnish in front.&lt;/strong&gt; A reverse proxy that serves cached pages without touching PHP-FPM is the single biggest TTFB lever for anonymous traffic. The &lt;a href="https://magevanta.com/blog/magento-2-full-page-cache-deep-dive" rel="noopener noreferrer"&gt;Full Page Cache deep dive&lt;/a&gt; explains why most product pages should be served entirely from cache, bypassing PHP and database on hit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. PHP frame and bootstrap
&lt;/h3&gt;

&lt;p&gt;On a cache-miss (or for logged-in customers, who usually bypass FPC per-section), TTFB is dominated by the PHP bootstrap and layout rendering. Biggest contributors, in order:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Collection and EAV queries.&lt;/strong&gt; Add the query list in the profiler; a handful of heavy collections on category/product pages often account for most of the DB time. The &lt;a href="https://magevanta.com/blog/magento-2-database-index-strategy-query-optimization" rel="noopener noreferrer"&gt;database index strategy guide&lt;/a&gt; shows how to find the offenders with &lt;code&gt;EXPLAIN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OPcache efficiency.&lt;/strong&gt; Ensure &lt;code&gt;opcache.enable=1&lt;/code&gt;, &lt;code&gt;opcache.validate_timestamps=0&lt;/code&gt; in production and a generous &lt;code&gt;opcache.memory_consumption&lt;/code&gt;. The &lt;a href="https://magevanta.com/blog/magento-2-php-opcache-tuning" rel="noopener noreferrer"&gt;PHP OPcache tuning guide&lt;/a&gt; covers sizing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout and blocks.&lt;/strong&gt; Rendering dozens of blocks is cheap only if their data is cached (blocks, layout cache, translations). The &lt;a href="https://magevanta.com/blog/magento-2-ui-component-performance-optimization" rel="noopener noreferrer"&gt;UI component guide&lt;/a&gt; and &lt;a href="https://magevanta.com/blog/magento-2-customer-data-sections-localstorage-performance" rel="noopener noreferrer"&gt;customer data sections guide&lt;/a&gt; reduce per-request work for logged-in traffic.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Database and Redis/OpenSearch round trips
&lt;/h3&gt;

&lt;p&gt;Cold product/category pages typically issue many backend calls. TTFB contributions here are really query latency times query count. Attack both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reduce query count&lt;/strong&gt; — avoid N+1 collections, load relations in bulk (&lt;code&gt;addAttributeToSelect&lt;/code&gt; in one pass rather than per-entity loops), and prefetch product options/stock in a single query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduce per-query latency&lt;/strong&gt; — correct indexes, warm buffer pool, and LOCAL cache for session/config. The &lt;a href="https://magevanta.com/blog/magento-2-mysql-read-write-replication-splitting" rel="noopener noreferrer"&gt;MySQL read/write replication split&lt;/a&gt; moves the read load off the primary and can shave tens of milliseconds per request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenSearch/Elasticsearch appears on search and layered-navigation requests. Watch &lt;code&gt;slowlog&lt;/code&gt; in the cluster; facet-heavy queries can add hundreds of milliseconds to the category request that runs them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Warm vs cold TTFB — track both
&lt;/h2&gt;

&lt;p&gt;Cold TTFB (a page no one has requested lately) is dominated by cache-miss work and fills around best with a generous FPC. Warm TTFB (a page already in Varnish/Redis) tests your proxy, connection and FPC-hit path. A healthy store should show warm TTFB in the tens of milliseconds and cold TTFB under roughly 400–600ms on the product path; anything above that on warm hits usually points at a proxy misconfig or a broken cache-tag invalidation that is recreating pages constantly. The &lt;a href="https://magevanta.com/blog/magento-2-cache-tag-invalidation-strategy" rel="noopener noreferrer"&gt;cache tag invalidation guide&lt;/a&gt; is the reference when pages never stay warm.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical reduction playbook
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure first&lt;/strong&gt;: scripted curls, warm and cold, plus an APM profile of the slowest URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the queue&lt;/strong&gt;: &lt;code&gt;max_children&lt;/code&gt;, slow-log, and health checks before touching PHP code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serve from cache&lt;/strong&gt;: ensure Varnish/FPC covers product and category pages for anonymous traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cut query count&lt;/strong&gt; on the uncached paths (login, cart, customer account).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify TLS/HTTP2/keepalive&lt;/strong&gt; so connection setup is not adding hundreds of milliseconds on repeat visits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-measure&lt;/strong&gt; after each change; a single metric (median warm TTFB) kept in CI with a budget prevents regressions, as described in &lt;a href="https://magevanta.com/blog/magento-2-automated-performance-regression-testing" rel="noopener noreferrer"&gt;automated performance regression testing&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;TTFB is the one number that makes all your other front-end work pay off. Get it under control first — every subsequent optimization (bundling, image sizing, lazy loading) shows a bigger effect on a fast first byte.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 PDF Generation Performance: Why Invoice PDFs Are Slow &amp; Memory-Hungry</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Mon, 31 Aug 2026 17:09:39 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-pdf-generation-performance-why-invoice-pdfs-are-slow-memory-hungry-8kj</link>
      <guid>https://dev.to/magevanta/magento-2-pdf-generation-performance-why-invoice-pdfs-are-slow-memory-hungry-8kj</guid>
      <description>&lt;p&gt;Click "Print" on an order and the browser sits there. Five seconds, ten seconds, sometimes a 504 from PHP-FPM — and on a big order, a memory spike that takes the admin pool down with it. Sales PDFs are one of the most quietly ignored performance problems in Magento 2: they're generated on every request, in pure PHP, with no cache, and the cost scales with the number of line items. This article shows exactly where that time goes, how to confirm it on your own store, and a practical playbook to make PDF generation fast — or get it out of the request path entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PDF Generation Is Expensive by Default
&lt;/h2&gt;

&lt;p&gt;When you print an invoice, packing slip, credit memo or order confirmation, Magento does &lt;strong&gt;not&lt;/strong&gt; render HTML and convert it. It draws the PDF programmatically, page by page, using &lt;code&gt;Zend_Pdf&lt;/code&gt; — the PDF engine from the Zend Framework 1 era that Magento still ships (via the &lt;code&gt;magento/zendframework1&lt;/code&gt; package). The classes that do the work live in &lt;code&gt;Magento\Sales\Model\Order\Pdf&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;AbstractPdf&lt;/code&gt; — the base class that lays out pages, draws the header, footer, logo and table scaffolding;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Invoice&lt;/code&gt;, &lt;code&gt;Shipment&lt;/code&gt;, &lt;code&gt;Creditmemo&lt;/code&gt;, &lt;code&gt;Order&lt;/code&gt; — each implements the item-by-item drawing for its document type.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The drawing loop is the hot path. For every line item, &lt;code&gt;AbstractPdf&lt;/code&gt; measures the rendered text width with &lt;code&gt;_getTextWidth()&lt;/code&gt;, which calls into the font object's &lt;code&gt;widthForString()&lt;/code&gt; — &lt;strong&gt;per character, in PHP&lt;/strong&gt;. Then &lt;code&gt;Zend_Pdf_Page::drawText()&lt;/code&gt; places every string at its coordinates, wrapping lines to fit the column. Add a logo image, a translated address block, item options, SKUs, tax rows, and a footer on every page, and a single invoice for an order with 100 line items easily takes &lt;strong&gt;1–5 seconds of pure CPU and 5–20 MB of memory per document&lt;/strong&gt; — all inside the PHP-FPM worker that is also serving your admin.&lt;/p&gt;

&lt;p&gt;The kicker: none of it is cached. Every print request calls &lt;code&gt;getPdf()&lt;/code&gt; again, re-measures every glyph, re-draws every page, and re-renders the whole document from scratch — even if nothing about the order changed since the last print.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the Time Actually Goes
&lt;/h2&gt;

&lt;p&gt;Profile a PDF request with Blackfire, Xdebug or a simple &lt;code&gt;microtime&lt;/code&gt; around &lt;code&gt;getPdf()&lt;/code&gt;, and the wall-clock time concentrates in three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Text measurement and drawing.&lt;/strong&gt; &lt;code&gt;Zend_Pdf_Font::widthForString()&lt;/code&gt; and &lt;code&gt;Zend_Pdf_Page::drawText()&lt;/code&gt; dominate. The cost is linear in the number of characters, so an order with 300 line items costs roughly three times what 100 items cost — plus the extra pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document assembly in memory.&lt;/strong&gt; &lt;code&gt;Zend_Pdf&lt;/code&gt; keeps every page object and its full content stream in memory until &lt;code&gt;render()&lt;/code&gt; flattens the document. A 50-page PDF is one big PHP object graph, and &lt;code&gt;Zend_Pdf::render()&lt;/code&gt; then serializes fonts and content streams — a second CPU spike right before the download starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch multiplication.&lt;/strong&gt; The admin "Print" action on the Sales &amp;gt; Orders grid (&lt;code&gt;Magento\Sales\Controller\Adminhtml\Order\Pdf&lt;/code&gt;) loops over &lt;strong&gt;every selected order in a single request&lt;/strong&gt;, concatenating all pages into one giant PDF before sending it. Select 100 orders with 40 items each and you're generating a 4000-line-item document synchronously. This is the classic 502 / 504 / memory-limit killer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There's also a frontend angle: customers can print their own order PDF from the account area, so a malicious or curious user can trigger expensive generation on the storefront, where the FPC does nothing for you — a downloaded PDF is dynamic by nature.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Diagnose It on Your Store
&lt;/h2&gt;

&lt;p&gt;Before changing anything, measure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pdf-bench.php - time one invoice PDF from the CLI&lt;/span&gt;
&lt;span class="k"&gt;require&lt;/span&gt; &lt;span class="no"&gt;BP&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/app/bootstrap.php'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nv"&gt;$bootstrap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;\Magento\Framework\App\Bootstrap&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$_SERVER&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$objectManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$bootstrap&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getObjectManager&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$invoice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$objectManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Sales\Model\Order\Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10012345&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$objectManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Sales\Model\Order\Pdf\Invoice&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getPdf&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"time: %.2fs, peak memory: %.1f MB&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;microtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nv"&gt;$start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;memory_get_peak_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice the fastest route is profiling one print request with Blackfire or Xdebug: look for &lt;code&gt;Zend_Pdf_Page::drawText&lt;/code&gt; and &lt;code&gt;Zend_Pdf_Font::widthForString&lt;/code&gt; in the hot methods list, and note &lt;code&gt;memory_get_peak_usage()&lt;/code&gt; at the end. Then scale the test: generate PDFs for orders with 10, 50 and 200 line items and plot time and memory against item count. If the curve is cleanly linear, you're textbook Zend_Pdf; if it's worse, check for a custom module or extension that adds per-item drawing work (extra columns, barcodes, images) — third-party invoice extensions frequently add the most expensive drawing of all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Playbook: Batch, Cache, Replace
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Get batch printing out of the request path (biggest win)
&lt;/h3&gt;

&lt;p&gt;Replace the synchronous mass-print flow with a queue consumer. The order grid mass action or custom button dispatches one message per order (or one message with an order-id batch), a consumer generates the PDFs and stores the files, and the admin sees a link or a "ready" flag once the job finishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- communication.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;config&lt;/span&gt; &lt;span class="na"&gt;xmlns:xsi=&lt;/span&gt;&lt;span class="s"&gt;"http://www.w3.org/2001/XMLSchema-instance"&lt;/span&gt;
        &lt;span class="na"&gt;xsi:noNamespaceSchemaLocation=&lt;/span&gt;&lt;span class="s"&gt;"urn:magento:framework:Communication/etc/communication.xsd"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;topic&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"sales.pdf.generate.request"&lt;/span&gt; &lt;span class="na"&gt;request=&lt;/span&gt;&lt;span class="s"&gt;"string"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;handler&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"pdf.generation.handler"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"Vendor\Module\Model\Pdf\Consumer"&lt;/span&gt; &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"process"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/topic&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/config&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consumer runs in a dedicated, monitored process with a &lt;strong&gt;higher memory limit&lt;/strong&gt; than the admin pool (&lt;code&gt;php -d memory_limit=1G bin/magento queue:consumers:start sales.pdf.generation&lt;/code&gt;), writes the finished files to a storage directory, and emits a notification with a download link. The admin request now takes milliseconds and returns instantly. This single change eliminates the 504s and the admin-pool memory crashes entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Cache generated documents
&lt;/h3&gt;

&lt;p&gt;If you keep synchronous generation (for example, the single-order print button), cache the rendered PDF keyed by document type + order/increment id + a content fingerprint, and invalidate when anything that appears on the PDF changes — a new comment, a shipment, a credit memo. The fingerprint can be as simple as a hash of &lt;code&gt;updated_at&lt;/code&gt; plus the relevant statuses; store files under &lt;code&gt;var/&lt;/code&gt; or the media directory, never in the database. A plug-in on &lt;code&gt;getPdf()&lt;/code&gt; is the clean interception point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- di.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;type&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"Magento\Sales\Model\Order\Pdf\Invoice"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;plugin&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"vendor_pdf_cache"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"Vendor\Module\Plugin\PdfCache"&lt;/span&gt; &lt;span class="na"&gt;sortOrder=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/type&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same route helps the frontend customer-print case: the file is generated once and served as a static download afterwards, which also protects you from print-happy customers hammering the storefront.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Generate PDFs ahead of time
&lt;/h3&gt;

&lt;p&gt;For high-volume stores, flip the order of work: generate the invoice PDF right after the invoice is created (or when it's finalized), triggered by an observer or a queue message, and store it. By the time a customer or admin asks for it, the file already exists. This turns a 2-second synchronous request into zero-cost delivery and completely decouples PDF CPU from storefront traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Make the renderer cheaper
&lt;/h3&gt;

&lt;p&gt;If you can't move generation off the request path, reduce the per-document cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trim the layout.&lt;/strong&gt; A module that overrides the PDF template classes can drop expensive columns (e.g., the extended options block, custom attributes, barcode images) or draw more items per page, cutting page count and glyph work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplify images and fonts.&lt;/strong&gt; The logo and any embedded images are re-encoded into every PDF; keep them small. Stick to the built-in core fonts (Helvetica, Times) — embedding custom TTF fonts forces font subsetting on every render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise limits for the right pool only.&lt;/strong&gt; Give the admin PHP-FPM pool (or the dedicated consumer pool) a higher &lt;code&gt;memory_limit&lt;/code&gt; and &lt;code&gt;pm.max_requests&lt;/code&gt; headroom, instead of raising limits globally for the storefront.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Replace Zend_Pdf entirely
&lt;/h3&gt;

&lt;p&gt;When PDFs are a core part of your business (thousands of invoices a day), consider replacing the renderer. Options range from faster pure-PHP engines (TCPDF, FPDF) to HTML-to-PDF converters (Chromium headless, wkhtmltopdf, or a PDF API service). The catch: Magento's PDF classes are tightly coupled to Zend_Pdf's page object model, so a full swap means reimplementing &lt;code&gt;Magento\Sales\Model\Order\Pdf\*&lt;/code&gt; classes with your own drawing layer while keeping the same public interface (&lt;code&gt;getPdf()&lt;/code&gt;), and validating every document type against your templates. It's real module work — start with the batch-queue and cache steps above first, and only invest in a renderer swap if profiling shows PDF rendering is a permanent, dominant cost for your infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting It Together
&lt;/h2&gt;

&lt;p&gt;The default Magento 2 PDF pipeline is a pure-PHP drawing engine, running synchronously in a web request, rebuilding the same document every time, with cost proportional to line items — and the mass-print grid action multiplies that by the number of selected orders in one shot. The fixes are progressive and independent:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Queue the batch print flow&lt;/strong&gt; — eliminate the 502/504 class of incidents overnight and move PDF CPU to monitored consumers with headroom.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache generated documents&lt;/strong&gt; — make repeat prints and frontend customer prints instant static downloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate ahead of time&lt;/strong&gt; if you invoice at scale — PDFs are cheap when nobody is waiting on them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim layout and fonts&lt;/strong&gt; to lower per-document cost, and scope memory limits to the generating pool only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace the renderer&lt;/strong&gt; only when profiling proves PDF generation is a dominant, permanent cost.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Start by timing &lt;code&gt;getPdf()&lt;/code&gt; on your largest orders — the numbers will tell you which of the five steps pays off first. In most stores, step 1 alone turns a dreaded admin click into an instant response, and that's usually worth more than any renderer swap.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Bundle Product Performance: Why 'Build Your Own' Products Slow Down Your Store</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Mon, 31 Aug 2026 09:04:34 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-bundle-product-performance-why-build-your-own-products-slow-down-your-store-4g7</link>
      <guid>https://dev.to/magevanta/magento-2-bundle-product-performance-why-build-your-own-products-slow-down-your-store-4g7</guid>
      <description>&lt;p&gt;Bundle products are the "build your own" workhorse of Magento 2 — a laptop with your choice of SSD, RAM and warranty; a gift box with three picked items; a PC configured per component. Merchants love them because they shift choice to the customer and lift average order value. What they don't love is the quiet way bundles multiply database rows, index work and request time exactly where they're already feeling it: price, stock, search and checkout.&lt;/p&gt;

&lt;p&gt;A configurable product is a parent with fixed, discrete variations — one row per variation in the &lt;a href="https://magevanta.com/blog/magento-2-price-index-performance" rel="noopener noreferrer"&gt;price index&lt;/a&gt;. A bundle is different: its price is &lt;em&gt;computed&lt;/em&gt; from whatever combination of selections the customer picks. That single sentence is the root of nearly every bundle performance problem in Magento 2. This article walks through where the costs land, how to diagnose them, and a practical playbook to keep bundles fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Bundles Are Structurally Expensive
&lt;/h2&gt;

&lt;p&gt;Every bundle product (module &lt;code&gt;Magento_Bundle&lt;/code&gt;) is backed by child products: the individual selectable SKUs, hidden with visibility &lt;em&gt;Not Visible Individually&lt;/em&gt;. The parent's price, stock status, weight and quote item only exist as a function of the selections. Concretely, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Price is a range, not a value.&lt;/strong&gt; The catalog price index must store the minimum and maximum possible price, per website, per store, per customer group — and every option selection a customer can make contributes to that math.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stock is derived.&lt;/strong&gt; The bundle's stock status is computed from the stock of all selection children (&lt;code&gt;cataloginventory_stock_status&lt;/code&gt; gets rows for every child).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote items are compound.&lt;/strong&gt; Adding one bundle to the cart creates the parent quote item &lt;em&gt;plus&lt;/em&gt; one child quote item per selected option — typically 3 to 10 extra rows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is broken; it's just that bundles turn "one product" into "one product plus N children plus M computed values" across every subsystem. On a catalog with thousands of bundles, that multiplication reaches the databases, queues and caches at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Price Index Multiplication
&lt;/h2&gt;

&lt;p&gt;This is the biggest cost center. In &lt;code&gt;catalog_product_index_price&lt;/code&gt;, a simple product is one row per website/group. A bundle explodes that with a dimension for every option selection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Catalog tables that drive bundle price computation&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_rows&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'catalog_product_bundle%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant tables — &lt;code&gt;catalog_product_bundle_option&lt;/code&gt;, &lt;code&gt;catalog_product_bundle_selection&lt;/code&gt;, &lt;code&gt;catalog_product_bundle_option_value&lt;/code&gt; — are small; the damage shows up in &lt;code&gt;catalog_product_index_price&lt;/code&gt;, where each bundle's min/max price is computed by iterating option selections during reindex. With &lt;code&gt;Magento_CatalogRule&lt;/code&gt; active, the bundle price rows multiply further, because rule prices are calculated against the same selection dimensions. The pattern is identical to the &lt;a href="https://magevanta.com/blog/magento-2-configurable-product-performance" rel="noopener noreferrer"&gt;configurable product price explosion&lt;/a&gt;, except configurables have a fixed variation set while bundles can have &lt;em&gt;combinations&lt;/em&gt; — the indexer can't precompute them, only bound them.&lt;/p&gt;

&lt;p&gt;Diagnose it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento indexer:status | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; price
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;catalog_product_price&lt;/code&gt; is perpetually behind in &lt;code&gt;Schedule&lt;/code&gt; mode or takes minutes in &lt;code&gt;Update on Save&lt;/code&gt; mode and your catalog has bundles — the bundle selection count is your lever. Reduce options per bundle, reduce selections per option, and definitely avoid &lt;em&gt;nested bundles&lt;/em&gt; (a bundle inside a bundle), which make the price computation recursive and multiply reindex time.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Stock, Reservations and the MSI Tax
&lt;/h2&gt;

&lt;p&gt;Bundle availability is derived from children, and under &lt;a href="https://magevanta.com/blog/magento-2-msi-performance-optimization" rel="noopener noreferrer"&gt;Multi-Source Inventory&lt;/a&gt; every child is its own inventory item. The chain works like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Each selection child gets rows in &lt;code&gt;inventory_source_item&lt;/code&gt; and &lt;code&gt;inventory_stock&lt;/code&gt; — stock status is a &lt;em&gt;computed aggregate&lt;/em&gt; per child.&lt;/li&gt;
&lt;li&gt;A bundle's stock status is then aggregated from all its children — a per-request query chain that grows with bundle size.&lt;/li&gt;
&lt;li&gt;On order placement, MSI writes one &lt;a href="https://magevanta.com/blog/magento-2-inventory-reservation-performance-optimization" rel="noopener noreferrer"&gt;inventory reservation&lt;/a&gt; row &lt;em&gt;per selected child&lt;/em&gt;. A 20-item bundle order generates 20+ reservation rows, accelerating the unbounded growth of &lt;code&gt;inventory_reservation&lt;/code&gt; that most stores are already fighting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The stock-status aggregation is especially visible when &lt;code&gt;display_out_of_stock&lt;/code&gt; is enabled: every out-of-stock child forces the bundle's status computation to re-evaluate on category and product pages. Keep bundle children's stock management synchronous, avoid "infinite" option combinations (MSI computes availability per combination), and monitor &lt;code&gt;inventory_reservation&lt;/code&gt; growth with the same cleanup cron you'd use for simple products — bundles just make it arrive sooner.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Quote, Cart and Checkout Bloat
&lt;/h2&gt;

&lt;p&gt;Add a bundle to the cart and open &lt;code&gt;quote_item&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 parent row with &lt;code&gt;product_type = 'bundle'&lt;/code&gt;, plus&lt;/li&gt;
&lt;li&gt;1 child row per selected option (&lt;code&gt;parent_item_id&lt;/code&gt; set).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A checkout with five bundles therefore writes anywhere from 20 to 60 quote item rows — and those rows live until the &lt;a href="https://magevanta.com/blog/magento-2-quote-table-optimization" rel="noopener noreferrer"&gt;quote is cleaned up&lt;/a&gt;. Every cart render (&lt;code&gt;getItems()&lt;/code&gt;), every totals collector run, every minicart AJAX call touches all of them. In orders, the same multiplication continues into &lt;code&gt;sales_order_item&lt;/code&gt; and follows through to the &lt;a href="https://magevanta.com/blog/magento-2-sales-order-performance-optimization" rel="noopener noreferrer"&gt;sales grid indexer&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Practical limits that keep this sane:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cap options per bundle at 5–8, selections per option at 10–15.&lt;/li&gt;
&lt;li&gt;Avoid re-rendering the cart: enable &lt;a href="https://magevanta.com/blog/magento-2-full-page-cache-deep-dive" rel="noopener noreferrer"&gt;full-page cache&lt;/a&gt; and AJAX-driven cart (the defaults) so the minicart doesn't rebuild quote totals on every page view.&lt;/li&gt;
&lt;li&gt;If you offer "pre-selected" bundles, remember they still create the full parent + child row set on add-to-cart — pre-selection saves &lt;em&gt;rendering&lt;/em&gt; time, not quote volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Search and Category Pages
&lt;/h2&gt;

&lt;p&gt;Bundle children are hidden with &lt;em&gt;Not Visible Individually&lt;/em&gt;, which is good — they're excluded from the search index. But the parent bundle still carries a computed price range into &lt;a href="https://magevanta.com/blog/magento-2-layered-navigation-performance" rel="noopener noreferrer"&gt;layered navigation&lt;/a&gt;, and that creates a subtle issue: the price filter and price sorting must use the &lt;em&gt;min&lt;/em&gt; price for ordering. If your SEO-friendliest bundles are also your most expensive at max configuration, they rank low in "price low-to-high" despite being competitive — a merchandising quirk, but also a hint that your search index is computing price ranges per query.&lt;/p&gt;

&lt;p&gt;Where bundles genuinely hurt search is &lt;em&gt;query time on large catalogs&lt;/em&gt;: price aggregation per bundle forces OpenSearch to evaluate the range per hit. Mitigate by keeping bundle children out of the index (default visibility does this) and by never enabling &lt;a href="https://magevanta.com/blog/magento-2-flat-catalog-performance-impact" rel="noopener noreferrer"&gt;flat catalog&lt;/a&gt; alongside bundles — flat tables materialize every child attribute and historically break or balloon with composite products.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Rendering the Product Page
&lt;/h2&gt;

&lt;p&gt;The bundle product page renders option widgets that must know each selection's price, stock and image. Out of the box that's a series of collection loads per option (&lt;code&gt;getSelectionsCollection()&lt;/code&gt; per option id), which shows up as N+1 patterns when you &lt;a href="https://magevanta.com/blog/magento-2-profiling-performance-debugging" rel="noopener noreferrer"&gt;profile the page&lt;/a&gt;. Look for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Frequent offenders in the slow query log / profiler&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;catalog_product_bundle_selection&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;catalog_product_entity&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each option = one such query chain. Fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Warm the FPC.&lt;/strong&gt; The bundle product page is fully cacheable for the anonymous customer. If your hit ratio is low, &lt;a href="https://magevanta.com/blog/magento-2-cache-warming-strategies" rel="noopener noreferrer"&gt;pre-warm bundle pages&lt;/a&gt; after reindexes — they're the most expensive product pages to build cold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch the selections.&lt;/strong&gt; Load all options for a product in one query (or via the bundle type's collection API with &lt;code&gt;addFilterByRequiredOptions&lt;/code&gt;) and assemble options in memory instead of per-option loading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disable dynamic pricing display&lt;/strong&gt; where you can: showing "from €X" per option on the product page triggers price recomputation per selection widget. If you only need the final price on selection, defer price rendering to the client-side price box.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Bundle Performance Playbook
&lt;/h2&gt;

&lt;p&gt;If you have bundles and they're slow, this is the order of operations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure first.&lt;/strong&gt; &lt;code&gt;indexer:status&lt;/code&gt; for price/stock lag, &lt;code&gt;information_schema&lt;/code&gt; row counts on &lt;code&gt;catalog_product_bundle_*&lt;/code&gt;, and a profiler trace of the worst product page. Bundles rarely fail on one axis; know which one hurts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrink the combos.&lt;/strong&gt; Fewer options, fewer selections, no nested bundles. This single change reduces price index rows, stock aggregations, quote rows and product-page rendering at once — it's the highest-leverage fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put price index on schedule with mview.&lt;/strong&gt; Ensure &lt;code&gt;catalog_product_price&lt;/code&gt; runs via &lt;a href="https://magevanta.com/blog/magento-2-mview-changelog-management" rel="noopener noreferrer"&gt;mview changelogs&lt;/a&gt; so save-time reindex doesn't stall admin operations, and batch it off-peak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch inventory reservations.&lt;/strong&gt; Add the reservation cleanup cron &lt;em&gt;before&lt;/em&gt; bundles become a big share of orders, not after the table hits tens of millions of rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep FPC warm.&lt;/strong&gt; Bundle pages are cacheable; actively warm them and check &lt;code&gt;X-Magento-Cache-Debug&lt;/code&gt; on the product page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider the trade-off.&lt;/strong&gt; If a "bundle" is really a fixed kit (three items, always the same), a &lt;a href="https://magevanta.com/blog/magento-2-product-relations-performance" rel="noopener noreferrer"&gt;grouped product&lt;/a&gt; or a simple product with a custom option set is structurally cheaper — same UX for the customer, none of the composite product machinery. Reserve true bundles for genuinely variable configurations.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Bundle products are one of the few Magento 2 features where the &lt;em&gt;architecture&lt;/em&gt; — derived price, derived stock, compound quote items — multiplies cost across every performance-sensitive subsystem. They're not unmanageable, but they demand discipline: constrained option trees, scheduled indexers, warm caches and reservation hygiene. Audit your bundles with the checks above before Black Friday traffic finds them for you.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
    </item>
    <item>
      <title>Magento 2 Catalog Permissions: The Hidden Full-Page Cache Killer</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Sun, 30 Aug 2026 09:03:19 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-catalog-permissions-the-hidden-full-page-cache-killer-9b8</link>
      <guid>https://dev.to/magevanta/magento-2-catalog-permissions-the-hidden-full-page-cache-killer-9b8</guid>
      <description>&lt;p&gt;Your store was fast. Category pages served from &lt;a href="https://magevanta.com/blog/magento-2-full-page-cache-deep-dive" rel="noopener noreferrer"&gt;full-page cache&lt;/a&gt; in single-digit milliseconds. Then someone enabled a "small B2B feature" in the admin, and suddenly every category page takes 800ms and the database CPU is pinned. If that feature was &lt;strong&gt;Catalog Permissions&lt;/strong&gt;, you've just met one of the most under-documented performance traps in Magento 2.&lt;/p&gt;

&lt;p&gt;Catalog permissions is a legitimate, powerful feature — it lets you control who can view categories, see prices, search, and check out, per customer group. But it comes with three hidden costs that most merchants discover only after the damage is done: full-page cache gets disabled on product and category pages, a permission index starts multiplying rows in the background, and every product collection query becomes heavier. This article walks through all three, how to diagnose them, and what to do when you genuinely need the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Catalog Permissions Actually Does
&lt;/h2&gt;

&lt;p&gt;The feature (module &lt;code&gt;Magento_CatalogPermissions&lt;/code&gt;) is enabled in admin under &lt;strong&gt;Stores → Configuration → Catalog → Catalog Permissions&lt;/strong&gt;. Once turned on you get four grants per customer group and per website, overridable per category:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Grant Catalog Category View&lt;/strong&gt; — who can browse a category tree&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant Catalog Product Price&lt;/strong&gt; — who can see prices&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant Checkout Items&lt;/strong&gt; — who can add to cart&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant Catalog Search&lt;/strong&gt; — who gets results in search&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The defaults are configured per website and customer group in the admin, and individual categories can override them. On the surface it's clean and flexible. Under the hood it's the closest thing Magento 2 has to a global performance switch you can flip by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost #1: Full-Page Cache Stops Working for the Pages That Matter
&lt;/h2&gt;

&lt;p&gt;This is the big one. Once catalog permissions are enabled, product and category content becomes customer-group-dependent: two visitors from different groups can legitimately see different category trees, different products, different prices. The shared, cacheable version of a category page no longer exists.&lt;/p&gt;

&lt;p&gt;Technically, Magento handles this via the permission-driven page context — the customer group becomes part of what determines page content, and for the affected pages the response is served as non-cacheable (you'll see &lt;code&gt;Cache-Control: private&lt;/code&gt; / &lt;code&gt;no-store&lt;/code&gt; in the headers instead of the Varnish-friendly public cache headers). In other words: every category and product page now hits PHP, Magento, and the database on &lt;strong&gt;every single request&lt;/strong&gt;. A store that was doing 2,000 uncached pages per second on a warm &lt;a href="https://magevanta.com/blog/magento-2-varnish-configuration-guide" rel="noopener noreferrer"&gt;Varnish&lt;/a&gt; setup suddenly needs the full stack to serve every category view.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://magevanta.com/blog/magento-2-customer-data-sections-localstorage-performance" rel="noopener noreferrer"&gt;customer data sections&lt;/a&gt; mechanism handles &lt;em&gt;small&lt;/em&gt; personalized blocks (mini cart, account links) on otherwise cached pages. It is not designed to rescue entire category listings or product pages — those are the page itself, not a block on it. No ESI trick or hole-punching workaround reliably restores caching for permission-gated listing pages; the content genuinely depends on who's asking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost #2: The Permission Index Multiplies Like the Price Index
&lt;/h2&gt;

&lt;p&gt;To avoid joining permission rules into every query at runtime, Magento builds a materialized permission index — think of it as a cousin of the &lt;a href="https://magevanta.com/blog/magento-2-price-index-performance" rel="noopener noreferrer"&gt;price index&lt;/a&gt;: rows are pre-computed per &lt;strong&gt;website × customer group × category × product&lt;/strong&gt; combination.&lt;/p&gt;

&lt;p&gt;Do the math. Five customer groups, three websites, 2,000 categories, 100,000 products — even with sparsity (permissions cascade from category defaults, so not every combination gets a row), the index can grow to millions of rows. Every rebuild is a full scan of that space. Reindexing the permission indexers after a mass product or category update is one of those "why is my maintenance window suddenly 40 minutes" moments.&lt;/p&gt;

&lt;p&gt;And this index isn't rebuilt once a day in quiet hours — it's an mview-based indexer, updated in near real time as category/products change. That means the growth also produces continuous &lt;a href="https://magevanta.com/blog/magento-2-mview-changelog-management" rel="noopener noreferrer"&gt;changelog and indexer load&lt;/a&gt; on top of your regular indexers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost #3: Every Collection Query Gets Heavier
&lt;/h2&gt;

&lt;p&gt;With permissions enabled, product and category collections carry an extra permission resolution step. When Magento loads a product collection it must filter it by what the current customer group is allowed to see, and when it renders the category tree it checks every node against the permission matrix.&lt;/p&gt;

&lt;p&gt;The typical symptoms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Category pages with a normal number of products suddenly generate noticeably more queries and joins, visible in the slow query log and in query counts per request&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;category navigation&lt;/strong&gt; (the tree in the header/sidebar) gets slower, because every node needs a permission check — on a large category tree this alone can add tens of milliseconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search results&lt;/strong&gt; are filtered per group too (that's the Grant Catalog Search grant), so search queries carry the same join overhead&lt;/li&gt;
&lt;li&gt;Admin operations that load collections can slow down as well, since the permission logic applies to a lot of shared collection code paths&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are catastrophic individually — but stacked on top of a disabled FPC, each uncached request now pays all of them at once. That's the perfect storm: the cache that used to hide the query cost is gone, and the queries themselves got more expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosing Whether Catalog Permissions Are Your Problem
&lt;/h2&gt;

&lt;p&gt;If your storefront slowed down and you suspect this feature, the checks are quick:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Is the feature enabled?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scope_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;core_config_data&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'catalog/magento_catalogpermissions/%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;enabled&lt;/code&gt; value of &lt;code&gt;1&lt;/code&gt; means the feature is on. Also check the grants (&lt;code&gt;grant_catalog_category_view&lt;/code&gt;, &lt;code&gt;grant_catalog_product_price&lt;/code&gt;, &lt;code&gt;grant_checkout_items&lt;/code&gt;, &lt;code&gt;grant_catalog_search&lt;/code&gt;) — even if enabled, the &lt;em&gt;effective&lt;/em&gt; restriction depends on their values.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Is FPC actually bypassed?&lt;/strong&gt;&lt;br&gt;
Fetch a category page with &lt;code&gt;curl -I&lt;/code&gt;. A healthy cached response carries &lt;code&gt;X-Magento-Cache-Debug: HIT&lt;/code&gt; and public cache headers. A permission-affected page shows &lt;code&gt;private&lt;/code&gt;/&lt;code&gt;no-store&lt;/code&gt; cache headers — that's the smoking gun.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. What do the permission indexers look like?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento indexer:status | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; permission
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "Catalog Permissions" indexers (category + product) should be &lt;code&gt;Schedule&lt;/code&gt;-managed and up to date. Check &lt;code&gt;catalogpermissions_product_index&lt;/code&gt; row count and growth trend in the database — if it's in the tens of millions, the index itself is now a load factor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Correlate the timeline.&lt;/strong&gt;&lt;br&gt;
Ask when the storefront slowed down and cross-reference with &lt;code&gt;core_config_data&lt;/code&gt; timestamps or audit logs. In almost every real case, the slowdown date matches the day someone flipped the feature on.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Question: Do You Actually Need It?
&lt;/h2&gt;

&lt;p&gt;Before optimizing, question the requirement. A large share of "we need catalog permissions" requests can be solved cheaper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Wholesale customers shouldn't see retail products"&lt;/strong&gt; → split catalogs across &lt;a href="https://magevanta.com/blog/magento-2-multistore-performance" rel="noopener noreferrer"&gt;store views or websites&lt;/a&gt; instead. Different websites keep full-page cache fully working and give you separate categories, pricing, and even separate &lt;a href="https://magevanta.com/blog/magento-2-layered-navigation-performance" rel="noopener noreferrer"&gt;layered navigation&lt;/a&gt; setups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Wholesale customers pay different prices"&lt;/strong&gt; → use customer group pricing / &lt;a href="https://magevanta.com/blog/magento-2-catalog-price-rules-vs-tier-prices" rel="noopener noreferrer"&gt;tier prices (or catalog price rules)&lt;/a&gt;. Group-based prices are rendered through customer data sections, so &lt;em&gt;pages stay cacheable&lt;/em&gt; while prices adapt per group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"We want to hide some categories from the menu"&lt;/strong&gt; → disable "Include in Menu" per category, or restructure the navigation. Zero permission machinery involved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Only logged-in B2B customers may see our full range"&lt;/strong&gt; → this is where permissions genuinely shine — but read the next section before committing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the requirement is truly &lt;em&gt;view-level gating by group with per-category overrides&lt;/em&gt;, catalog permissions is the right tool and you should keep it — just understand the trade-off and budget for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  If You Must Keep It: The Optimization Playbook
&lt;/h2&gt;

&lt;p&gt;You can't get FPC back for permission-gated pages — accept that. But you can keep the damage contained:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the permission matrix coarse.&lt;/strong&gt; The index cost grows with the number of customer groups, websites, categories, and products in play. Collapse groups where possible ("Retail", "Wholesale", "Guest" instead of ten micro-segments), and prefer group-level defaults with only a few category overrides instead of per-category rules everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch &lt;a href="https://magevanta.com/blog/magento-2-customer-segment-performance" rel="noopener noreferrer"&gt;customer segments&lt;/a&gt; and permissions together.&lt;/strong&gt; Combining both multiplies per-request work: segment conditions evaluate on top of the permission joins. If both are enabled, profile where the time actually goes — often the segments are the larger part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manage the permission indexers deliberately.&lt;/strong&gt; Keep them on &lt;code&gt;Schedule&lt;/code&gt; (mview) with sane batch sizes, and align their update windows with your other indexer load instead of letting them fight &lt;a href="https://magevanta.com/blog/magento-2-indexer-optimization" rel="noopener noreferrer"&gt;the main indexers&lt;/a&gt; for CPU during business hours. Monitor &lt;code&gt;catalogpermissions_product_index&lt;/code&gt; growth — if it trends toward tens of millions of rows, your category/group structure is the problem, not the indexer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tune the database for the new query pattern.&lt;/strong&gt; The permission joins hit the EAV and index tables heavily. Apply the usual medicine: proper composite indexes on the permission tables' foreign keys (website_id, customer_group_id, category_id, product_id), a healthy &lt;a href="https://magevanta.com/blog/magento-2-mysql-query-cache-vs-magento-cache" rel="noopener noreferrer"&gt;buffer pool&lt;/a&gt;, and &lt;a href="https://magevanta.com/blog/magento-2-slow-queries-fix" rel="noopener noreferrer"&gt;query analysis&lt;/a&gt; after enabling the feature — the slow query log will tell you exactly which joins need help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revisit the search grant.&lt;/strong&gt; If wholesale needs a separate search experience, check whether both groups really need full-catalog search. Restricting search to a subset changes the search index relevance work, and it's one more place where the permission join runs on every query.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load test before rolling out.&lt;/strong&gt; Enable permissions in staging, run your &lt;a href="https://magevanta.com/blog/magento-2-load-testing-capacity-planning" rel="noopener noreferrer"&gt;load tests&lt;/a&gt; against the uncached permission-gated pages, and measure exactly what "no more FPC" costs you in your traffic mix. Decide whether the business value of the feature justifies the extra PHP-FPM and database capacity — and whether your &lt;a href="https://magevanta.com/blog/magento-2-php-fpm-tuning" rel="noopener noreferrer"&gt;PHP-FPM tuning&lt;/a&gt; can absorb it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Catalog permissions is a dangerous default-on-by-accident feature: it silently disables full-page cache on product and category pages, multiplies an index table, and adds permission joins to nearly every collection — all for the cost of a checkbox. Before enabling it, confirm the business requirement genuinely needs per-group &lt;em&gt;view&lt;/em&gt; gating, not just different pricing or menu structure. If it does, keep the permission matrix coarse, manage the indexers deliberately, and load-test the uncached reality.&lt;/p&gt;

&lt;p&gt;And if your store already slowed down and you never consciously enabled this feature — check &lt;code&gt;core_config_data&lt;/code&gt; for &lt;code&gt;catalog/magento_catalogpermissions&lt;/code&gt;, check the cache headers on a category page, and check the permission index size. In the majority of cases, the "mysterious B2B slowdown" is just this one toggle doing exactly what it says on the tin.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 MariaDB vs MySQL: Choosing the Database Server That Won't Slow You Down</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Sat, 29 Aug 2026 09:03:58 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-mariadb-vs-mysql-choosing-the-database-server-that-wont-slow-you-down-5f2f</link>
      <guid>https://dev.to/magevanta/magento-2-mariadb-vs-mysql-choosing-the-database-server-that-wont-slow-you-down-5f2f</guid>
      <description>&lt;p&gt;Ask most Magento 2 teams which database server they run and you get a shrug. It came with the hosting plan, the VPS image, or the Docker container. But "MySQL-compatible" is not "the same server" — and on a platform that fires hundreds of small queries per page view, the differences between MySQL and MariaDB can show up in your &lt;a href="https://magevanta.com/blog/magento-2-slow-queries-fix" rel="noopener noreferrer"&gt;slow query log&lt;/a&gt; sooner than you think.&lt;/p&gt;

&lt;p&gt;This article compares both servers from a Magento 2 perspective: version support, optimizer behaviour, clustering, the gotchas that bite during migration, and a practical decision framework for your next server build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Choice Matters More Than You'd Expect
&lt;/h2&gt;

&lt;p&gt;Magento 2 is a query machine. A category page with 24 products can easily trigger 300+ queries once cache misses kick in — EAV attribute lookups, price index reads, URL rewrites, stock reservations. The database server sits in the critical path of every uncached request, every indexer run, and every checkout.&lt;/p&gt;

&lt;p&gt;The good news: for this exact workload, MySQL 8.0 and MariaDB 10.6+ are within a few percent of each other &lt;em&gt;when both are tuned properly&lt;/em&gt;. The bad news: they differ in defaults, optimizer behaviour, and operational tooling — and those differences decide how much tuning you need, which extensions break, and how painful your next migration is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version Support: What Adobe Actually Allows
&lt;/h2&gt;

&lt;p&gt;Adobe officially supports both. As of Magento 2.4.7 and 2.4.8, the system requirements list &lt;strong&gt;MySQL 8.0.x&lt;/strong&gt; and &lt;strong&gt;MariaDB 10.6+&lt;/strong&gt; (earlier 2.4 releases also accepted MariaDB 10.4/10.5). Percona Server 8.0 is the third supported option and sits close to MySQL with extra tooling.&lt;/p&gt;

&lt;p&gt;What this means in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MariaDB 10.6 is the safe floor.&lt;/strong&gt; Anything below 10.4 is unsupported territory — and plenty of cheap hosts still ship 10.3 or even 10.1. Check before you buy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MariaDB 11.x is running on a rebased InnoDB.&lt;/strong&gt; Starting with 11.0, MariaDB's InnoDB is forked from the MySQL 5.7 code base rather than 8.0. Functionally it works fine for Magento, but some features and optimizations from MySQL 8's InnoDB are absent, and the community has reported mixed performance results. Test before you adopt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL 8.0 is the default if your stack is built around it.&lt;/strong&gt; If your host offers both, either is defensible — see below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Compatibility Gotchas That Bite During Setup
&lt;/h2&gt;

&lt;p&gt;Most "it worked on my old host" breakages when switching between the two come from these four areas:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Authentication plugins
&lt;/h3&gt;

&lt;p&gt;MySQL 8.0 changed the default authentication plugin to &lt;code&gt;caching_sha2_password&lt;/code&gt;. Modern PHP (7.4+, so any Magento 2.4.8 stack) handles this fine, but if you're connecting with an older connector, a legacy tool, or a monitoring agent, you'll get an immediate &lt;code&gt;Access denied for user&lt;/code&gt; — even with the correct password. The quick fix is creating the Magento user with &lt;code&gt;mysql_native_password&lt;/code&gt;, but plan for it instead of debugging it at 2 AM during a migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. SQL mode defaults
&lt;/h3&gt;

&lt;p&gt;MySQL 8 defaults to &lt;code&gt;ONLY_FULL_GROUP_BY&lt;/code&gt; and &lt;code&gt;STRICT_TRANS_TABLES&lt;/code&gt;; MariaDB's default set differs slightly. Third-party extension SQL that was written for MariaDB's looser grouping semantics can throw errors on MySQL — and vice versa. If you migrate and suddenly see SQL errors from extensions you didn't touch, check &lt;code&gt;sql_mode&lt;/code&gt; first.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Feature parity gaps
&lt;/h3&gt;

&lt;p&gt;Both support window functions, CTEs and common index types, but not identically. MariaDB has no direct equivalent of MySQL 8's &lt;code&gt;INVISIBLE&lt;/code&gt; indexes or hash-join cost model tuned the same way; MySQL lacks MariaDB's system-versioned tables and &lt;code&gt;SEQUENCE&lt;/code&gt; engines. Magento core rarely touches these, but custom modules and reporting queries sometimes do — grep your codebase's raw SQL before switching.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. JSON handling
&lt;/h3&gt;

&lt;p&gt;Magento 2 itself barely uses JSON columns, but extensions (especially headless/custom-api modules) do. MySQL 8 has native JSON with generated-column indexing; MariaDB treats JSON as an alias for &lt;code&gt;LONGTEXT&lt;/code&gt; with a validation check. Queries that rely on &lt;code&gt;-&amp;gt;&amp;gt;&lt;/code&gt; or generated indexes behave differently. If your stack uses JSON columns heavily, this can be a deciding factor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance: Where They Actually Differ
&lt;/h2&gt;

&lt;p&gt;For a Magento workload, benchmark suites usually land close — within 5–10%, and the winner flips depending on the query mix and config. The structural differences that matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;MySQL 8.0&lt;/th&gt;
&lt;th&gt;MariaDB 10.6+&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;InnoDB base&lt;/td&gt;
&lt;td&gt;8.0 line, continuously improved&lt;/td&gt;
&lt;td&gt;5.7 fork (10.6), 5.7-based (11.x)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optimizer&lt;/td&gt;
&lt;td&gt;Cost model, hash joins, descending indexes, invisible indexes&lt;/td&gt;
&lt;td&gt;Good cost model, some 5.7-era optimizations; no hash joins until recently planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Thread pool&lt;/td&gt;
&lt;td&gt;Enterprise only&lt;/td&gt;
&lt;td&gt;In community server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clustering&lt;/td&gt;
&lt;td&gt;Group Replication / InnoDB Cluster (MySQL Shell)&lt;/td&gt;
&lt;td&gt;Galera (mature, battle-tested)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Performance Schema&lt;/td&gt;
&lt;td&gt;On by default, measurable overhead&lt;/td&gt;
&lt;td&gt;Available, lighter default footprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backup tooling&lt;/td&gt;
&lt;td&gt;mysqlbackup / xtrabackup (8.0-compatible builds)&lt;/td&gt;
&lt;td&gt;mariabackup (drop-in for xtrabackup)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extras&lt;/td&gt;
&lt;td&gt;Resource groups, &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;System-versioned tables, &lt;code&gt;ANALYZE FORMAT=JSON&lt;/code&gt; counter-part, more storage engines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things matter more than the brand:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration is 90% of the game.&lt;/strong&gt; Either server will crawl with default config on a Magento store. &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt; sized to your dataset, sensible &lt;code&gt;innodb_flush_log_at_trx_commit&lt;/code&gt;, &lt;code&gt;join_buffer_size&lt;/code&gt; for the infamous EAV joins — these matter far more than which server binary you run. If you haven't tuned these yet, start with our &lt;a href="https://magevanta.com/blog/magento-2-database-index-strategy-query-optimization" rel="noopener noreferrer"&gt;database index strategy guide&lt;/a&gt; before switching servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The optimizer makes different choices.&lt;/strong&gt; The same query can take a different plan on each server because the cost models differ. That means: after migrating, re-check your slow query log and your &lt;a href="https://magevanta.com/blog/magento-2-indexer-optimization" rel="noopener noreferrer"&gt;indexer performance&lt;/a&gt;. A query that used index A on MySQL may use a full scan on MariaDB — or vice versa. This is normal, and it's the #1 reason a migration "feels slower" even though the hardware didn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clustering and Replication
&lt;/h2&gt;

&lt;p&gt;If you're running more than one database node, the decision gets more interesting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MariaDB Galera&lt;/strong&gt; is the classic synchronous multi-master setup. Mature, widely deployed, and it works well with Magento's &lt;a href="https://magevanta.com/blog/magento-2-mysql-read-write-replication-splitting" rel="noopener noreferrer"&gt;read/write splitting&lt;/a&gt; patterns — just be careful with the write-hotspots (cart, sales_order, inventory_reservation) that can cause certification conflicts and &lt;a href="https://magevanta.com/blog/magento-2-database-deadlocks-detection-prevention" rel="noopener noreferrer"&gt;deadlocks&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL InnoDB Cluster / Group Replication&lt;/strong&gt; is the modern equivalent. Tighter integration with MySQL Shell and Router, but with a steeper learning curve and its own quirks (e.g., multi-primary limitations, stricter membership handling).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most Magento stores — even fairly large ones — a single well-tuned node with async replica(s) is the right architecture regardless of brand. Clustering is a scale pattern, not a default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration Checklist: Switching Without a Fire Drill
&lt;/h2&gt;

&lt;p&gt;If you're moving from one to the other, treat it as a project, not a &lt;code&gt;mysqldump | mysql&lt;/code&gt; one-liner:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the version matrix.&lt;/strong&gt; Confirm your Magento version supports the target server version — &lt;a href="https://experienceleague.adobe.com/en/docs/commerce-operations/installation-guide/system-requirements" rel="noopener noreferrer"&gt;check the official requirements&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage it first.&lt;/strong&gt; Stand up the target server, restore a full backup, and run your real indexers + a smoke test on the storefront.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare query plans.&lt;/strong&gt; Capture the top 20 slow queries before migration, run them on the target, and diff the &lt;code&gt;EXPLAIN&lt;/code&gt; output. Expect differences; fix what regressed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check extensions.&lt;/strong&gt; Any module doing raw SQL, JSON columns, or GROUP BY without care is suspect. Test the ones you can't live without.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-tune config.&lt;/strong&gt; Don't copy &lt;code&gt;my.cnf&lt;/code&gt; verbatim — &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt; and related InnoDB variables map across, but optimizer and thread settings differ. Start from the target server's defaults + your buffer-pool sizing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the right backup tool.&lt;/strong&gt; &lt;code&gt;mariabackup&lt;/code&gt; for MariaDB, &lt;code&gt;xtrabackup&lt;/code&gt; 8.0 for MySQL. Logical dumps (&lt;code&gt;mysqldump&lt;/code&gt;) work but are slower and heavier for multi-GB catalogs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch the first week.&lt;/strong&gt; Slow query log, &lt;a href="https://magevanta.com/blog/magento-2-database-deadlocks-detection-prevention" rel="noopener noreferrer"&gt;deadlock log&lt;/a&gt;, and indexer runtime. Schedule a &lt;a href="https://magevanta.com/blog/magento-2-database-maintenance-cleanup-strategy" rel="noopener noreferrer"&gt;database maintenance pass&lt;/a&gt; shortly after.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to Decide: A Practical Framework
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose MySQL 8.0 if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your team already operates MySQL / Percona in production&lt;/li&gt;
&lt;li&gt;You rely on JSON columns, generated columns, or &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;You want MySQL 8's optimizer features (hash joins, descending indexes)&lt;/li&gt;
&lt;li&gt;Your host's managed service is MySQL-based (RDS, Cloud SQL)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose MariaDB 10.6+ if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your host ships MariaDB (cPanel, most cheap VPS images) and you want the supported default path&lt;/li&gt;
&lt;li&gt;You want Galera for synchronous multi-master&lt;/li&gt;
&lt;li&gt;You value the thread pool in community builds for high concurrent-connection workloads&lt;/li&gt;
&lt;li&gt;Your team knows MariaDB tooling (&lt;code&gt;mariabackup&lt;/code&gt;, &lt;code&gt;mysql&lt;/code&gt; CLI compat)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Avoid the brand debate entirely if:&lt;/strong&gt; you're running a single node and your &lt;a href="https://magevanta.com/blog/magento-2-slow-queries-fix" rel="noopener noreferrer"&gt;slow queries&lt;/a&gt;, &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/innodb-buffer-pool.html" rel="noopener noreferrer"&gt;buffer pool&lt;/a&gt;, and &lt;a href="https://magevanta.com/blog/magento-2-database-index-strategy-query-optimization" rel="noopener noreferrer"&gt;indexes&lt;/a&gt; are untuned. That's where your performance actually lives. Switching servers to fix a slow store is like changing the oil brand to fix a flat tire.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;MySQL 8.0 and MariaDB 10.6+ are both fully capable Magento 2 database servers. For the typical store the performance difference is noise compared to configuration, indexing and query design. Choose based on what your host provides and what your team can operate — then invest your time in the things that actually move the needle: buffer pool sizing, composite indexes on the &lt;a href="https://magevanta.com/blog/magento-2-price-index-performance" rel="noopener noreferrer"&gt;EAV and price tables&lt;/a&gt;, archiving old &lt;a href="https://magevanta.com/blog/magento-2-database-table-partitioning-archiving" rel="noopener noreferrer"&gt;sales and quote data&lt;/a&gt;, and a decent &lt;a href="https://magevanta.com/blog/magento-2-database-connection-pooling" rel="noopener noreferrer"&gt;connection pool&lt;/a&gt; so MySQL/MariaDB never becomes the wall between PHP-FPM and your data.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Load Testing &amp; Capacity Planning: Know Your Limits Before Traffic Does</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Fri, 28 Aug 2026 09:03:06 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-load-testing-capacity-planning-know-your-limits-before-traffic-does-1njm</link>
      <guid>https://dev.to/magevanta/magento-2-load-testing-capacity-planning-know-your-limits-before-traffic-does-1njm</guid>
      <description>&lt;p&gt;Every Magento 2 team has the same nightmare: a flash sale goes live, traffic triples, and the site turns into a spinning wheel of death. The store survives — barely — but orders drop, support tickets explode, and the post-mortem reveals the same sentence: "We didn't know it would break at that load."&lt;/p&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; know. Load testing is the difference between guessing your limits and measuring them. This article covers the full loop: designing realistic tests, generating load that actually resembles your shoppers, reading the results to find the real bottleneck, and turning those numbers into capacity decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Load Testing Is Not Regression Testing
&lt;/h2&gt;

&lt;p&gt;If you've read our article on &lt;a href="https://magevanta.com/blog/magento-2-automated-performance-regression-testing" rel="noopener noreferrer"&gt;automated performance regression testing in CI&lt;/a&gt;, you know that's about catching &lt;em&gt;slowdowns&lt;/em&gt; between deploys — a few requests, tight budgets, fail the build if TTFB climbs.&lt;/p&gt;

&lt;p&gt;Load testing answers a different question: &lt;strong&gt;how much traffic can this system handle before it degrades or dies?&lt;/strong&gt; One focuses on change detection; the other on absolute capacity. You need both. Regression testing keeps you from getting slower; load testing tells you where the cliff is, and whether one node survives a flash sale or you need five.&lt;/p&gt;

&lt;h2&gt;
  
  
  Define What "Good" Means Before You Start
&lt;/h2&gt;

&lt;p&gt;A load test without acceptance criteria is just a benchmark with anxiety. Define SLOs first, ideally from real traffic data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;p95 Time To First Byte (TTFB)&lt;/strong&gt; under load — e.g., under 800ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error rate&lt;/strong&gt; — under 0.5% (502s, timeouts, checkout failures)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throughput&lt;/strong&gt; — X requests/second sustainable for 30+ minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business metrics&lt;/strong&gt; — successful checkout completion rate over 99%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then define the &lt;em&gt;shape&lt;/em&gt; of traffic. Magento 2 is not a static site: different pages cost wildly different amounts. A realistic mix for a typical store looks something like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;40% category/product listing pages (PHP + FPC + Elasticsearch aggregations)&lt;/li&gt;
&lt;li&gt;30% product detail pages (heavily cached, cheap when warm)&lt;/li&gt;
&lt;li&gt;15% home + CMS pages (nearly free with FPC)&lt;/li&gt;
&lt;li&gt;10% cart + checkout actions (uncached, DB-heavy, the real load)&lt;/li&gt;
&lt;li&gt;5% search, account, and API calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also model the &lt;em&gt;audience&lt;/em&gt;. Returning customers with a valid session hit a different code path than anonymous shoppers — customer data sections, personalized blocks, and lesser FPC coverage. If 60% of your real traffic is logged in, a test with 100% anonymous visitors will flatter you dangerously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a Test Environment That Looks Like Production
&lt;/h2&gt;

&lt;p&gt;The #1 load-testing mistake: testing on a sandbox with 50 products, one customer, and a cold cache — then believing the results apply to your 200k-SKU storefront.&lt;/p&gt;

&lt;p&gt;Your test environment needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Production-sized data.&lt;/strong&gt; Same catalog size, same attribute count, realistic customer and quote tables. EAV lookups and index tables behave completely differently at 10k vs 200k SKUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production-equivalent config.&lt;/strong&gt; Same number of PHP-FPM workers (or scaled proportionally), same Redis setup, same Elasticsearch/OpenSearch cluster layout, same Varnish/FPC strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm cache tests first, cold cache tests second.&lt;/strong&gt; A warm-cache test measures steady-state capacity — what shoppers experience 99% of the time. A cold-cache test (flush Varnish + Redis, burst traffic) simulates the worst minutes after a deploy or a cache invalidation storm. Both are informative; most teams only test one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run load from a different machine.&lt;/strong&gt; The load generator should never share resources with the app server. If possible, generate traffic from outside your CDN too — you want to see the origin's real behavior, not just the CDN's edge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tooling: k6 or JMeter?
&lt;/h2&gt;

&lt;p&gt;For Magento 2 specifically, both work; pick based on your team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;k6&lt;/strong&gt; — scriptable in JavaScript, excellent ramp-up/ramp-down stages, cheap to run in CI, and produces clean threshold-based pass/fail. Great if you want to reuse the same journey definitions between load tests and synthetic checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JMeter&lt;/strong&gt; — heavyweight, GUI-driven, familiar to many QA teams, with a huge plugin ecosystem (including Magento-specific CSV data sets or correlation helpers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Locust&lt;/strong&gt; — Python, fine for simple journeys, but its event loop can become the bottleneck at high concurrency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal k6 journey for a shopper flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k6/http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sleep&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;k6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;// ramp up&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;20m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// steady state&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2m&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;    &lt;span class="c1"&gt;// ramp down&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;thresholds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;http_req_duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;p(95)&amp;lt;800&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;http_req_failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rate&amp;lt;0.005&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Anonymous browse: home → PLP → PDP&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;home&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://store.example.com/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;home&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home 200&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://store.example.com/women/tops.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;plp 200&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://store.example.com/example-product.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pdp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdp 200&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Every Nth iteration: add to cart + go to checkout (uncached, heavy)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__ITER&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// In reality: carry form_key from the PDP, POST to checkout/cart/add&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://store.example.com/checkout/cart/add/uenc/...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cart add 200&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// realistic think time between pages&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The think time matters more than people assume. 50 virtual users hammering pages back-to-back with zero delay is 50 users acting like robots — your real visitors read, scroll, compare, and hesitate. Without think time you'll overestimate load on cheap cached pages and misread the results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extract the Right Data From the Test
&lt;/h2&gt;

&lt;p&gt;A load test produces two data sets: the &lt;em&gt;client-side&lt;/em&gt; response times, and the &lt;em&gt;server-side&lt;/em&gt; telemetry. The second one is where the diagnosis lives. During the test, watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHP-FPM.&lt;/strong&gt; Is &lt;code&gt;pm.max_children&lt;/code&gt; exhausted? You'll see &lt;code&gt;listen queue&lt;/code&gt; grow and 502s appear. This is the classic first bottleneck for PHP apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL/MariaDB.&lt;/strong&gt; Run &lt;code&gt;SHOW PROCESSLIST&lt;/code&gt; and watch &lt;code&gt;Threads_running&lt;/code&gt;, &lt;code&gt;Threads_connected&lt;/code&gt;, and slow query log entries. A wave of identical slow queries (often the same category listing or price filter query) points straight at the culprit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis.&lt;/strong&gt; &lt;code&gt;INFO stats&lt;/code&gt; — look at &lt;code&gt;evicted_keys&lt;/code&gt; and &lt;code&gt;rejected_connections&lt;/code&gt;; evictions under load mean the cache is thrashing, not helping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elasticsearch/OpenSearch.&lt;/strong&gt; Watch search latency and rejection counts (&lt;code&gt;thread_pool&lt;/code&gt; stats). Faceted navigation is often the hidden load generator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nginx access log.&lt;/strong&gt; Triage the response code mix and the slowest URLs. &lt;code&gt;awk&lt;/code&gt; over the log for p95 per URL pattern tells you which page types degrade first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bottleneck is almost always a &lt;em&gt;queue&lt;/em&gt; filling up: PHP-FPM workers, DB connections, or search threads. When response times climb linearly while CPU idles, you're queueing somewhere — find the queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the Results: The Three Load Phases
&lt;/h2&gt;

&lt;p&gt;Healthy systems show three phases in a ramp-up test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Linear phase&lt;/strong&gt; — response time stays flat as concurrency grows. The system is bored. This is your linear capacity region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Knee phase&lt;/strong&gt; — response time starts climbing, but throughput still grows. Some queue is starting to fill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cliff phase&lt;/strong&gt; — throughput plateaus or drops, error rate spikes. Saturated. This is your breaking point.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Never capacity-plan at the cliff. Plan at the &lt;em&gt;knee&lt;/em&gt;: the concurrency where p95 stays inside your SLO. If your knee is at 40 concurrent sessions per node and you expect 400 at peak, you need ~10 nodes with zero headroom — plan for 30-40% headroom on top of that for spikes, deploys, and cache misses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning Numbers Into Capacity Decisions
&lt;/h2&gt;

&lt;p&gt;This is where load testing pays for itself. Concrete examples of decisions the test output should drive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node count.&lt;/strong&gt; One web node sustains X req/s at p95 under SLO → peak demand is 3.5X → you need 4-5 nodes, not 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Autoscaling thresholds.&lt;/strong&gt; Set scale-out at 60-70% of the knee value, not at error-rate spikes. Scaling on errors is like braking after the crash.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where to spend money.&lt;/strong&gt; If the bottleneck is MySQL connection saturation, buying two more web nodes won't help — add a read replica or enable connection pooling instead. Load tests redirect your budget from symptoms to cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache warming before launches.&lt;/strong&gt; If the cold-cache test fails and the warm-cache test passes, your launch procedure needs a warming step (sitemap crawl or prioritized warm) &lt;em&gt;before&lt;/em&gt; traffic switches over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Queue-based offloading.&lt;/strong&gt; If checkout actions are the cliff, move email, order export, and inventory updates to async message queues so a traffic spike doesn't compound into a DB pileup.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to Test — And How Often
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before every major event&lt;/strong&gt;: Black Friday, seasonal sales, product launches, flash deals. Re-run the test with the &lt;em&gt;expected&lt;/em&gt; peak traffic at least a week ahead — enough time to fix what it finds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After architecture changes&lt;/strong&gt;: moving to a new hosting stack, adding a CDN, switching search engines, changing PHP versions. Every one of these shifts the knee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarterly baseline&lt;/strong&gt;: as your catalog grows, your capacity profile drifts. A quarterly 30-minute soak test keeps your numbers honest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After the fact — always validate.&lt;/strong&gt; During your next real peak, compare actual server metrics to the test predictions. If reality is 2x better or worse than the test, your test data or think times are off — fix the model, or the next test will lie to you again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;Load testing doesn't prevent traffic spikes — but it removes the &lt;em&gt;surprise&lt;/em&gt;. It tells you exactly how many requests your stack absorbs before it complains, where the bottleneck lives, and what one more euro of infrastructure should buy (another node, a replica, or a cache). Combined with CI regression testing, it forms the complete picture: you stay fast &lt;em&gt;and&lt;/em&gt; you know your limits.&lt;/p&gt;

&lt;p&gt;Run warm and cold tests, model real shopper behavior, watch the server-side queues during the run, and plan at the knee with headroom. Do that before the next flash sale, and the only thing spinning will be your load generator — not your storefront.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Vite Build Pipeline: The Modern Frontend Bundle Path</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Thu, 27 Aug 2026 09:01:43 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-vite-build-pipeline-the-modern-frontend-bundle-path-1pc9</link>
      <guid>https://dev.to/magevanta/magento-2-vite-build-pipeline-the-modern-frontend-bundle-path-1pc9</guid>
      <description>&lt;p&gt;For years, compiling frontend assets in Magento 2 meant one of two things: the classic &lt;code&gt;bin/magento setup:static-content:deploy&lt;/code&gt; pipeline backed by a Grunt/Stylus/&lt;code&gt;css-m&lt;/code&gt; stack, or the endless patience required to watch that pipeline churn through thousands of theme files. If you've ever run a full multi-locale static content deploy on a large site, you know the pain: minutes of CSS and JS processing, a flood of &lt;code&gt;pub/static&lt;/code&gt; files, and deployment pipelines that stall waiting for the frontend to finish.&lt;/p&gt;

&lt;p&gt;Starting with &lt;strong&gt;Magento 2.4.7&lt;/strong&gt;, Adobe added experimental support for &lt;strong&gt;Vite&lt;/strong&gt; — the modern JavaScript build tool built on esbuild and Rollup. It's a faster, cleaner, more maintainable way to compile your theme assets, and it directly attacks one of the most frustrating performance problems in the Magento ecosystem: slow, opaque frontend builds.&lt;/p&gt;

&lt;p&gt;This article is a practical guide to the Vite build path for Magento 2: what it replaces, how to turn it on, what the trade-offs are, and whether it's right for your store yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Classic Pipeline Struggles
&lt;/h2&gt;

&lt;p&gt;The traditional Magento frontend build has a few structural problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It's a multi-tool chain.&lt;/strong&gt; Grunt orchestrates, Stylus preprocesses CSS, the &lt;code&gt;css-m&lt;/code&gt; task modularizes, and &lt;code&gt;setup:static-content:deploy&lt;/code&gt; copies, merges, and minifies. That's a lot of moving parts with a lot of I/O between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It processes everything, every time.&lt;/strong&gt; Unless you carefully scope themes and locales, the deploy builds every theme and every locale's assets, copying thousands of files into &lt;code&gt;pub/static&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's slow to iterate.&lt;/strong&gt; During development, full deploys are brutally slow, which pushes developers toward half-measures and skips.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Vite replaces essentially this entire chain with a single modern tool that compiles dramatically faster and offers a proper development server with hot module replacement (HMR).&lt;/p&gt;

&lt;h2&gt;
  
  
  How Experimental Vite Works in Magento
&lt;/h2&gt;

&lt;p&gt;Magento's Vite integration is opt-in and experimental in 2.4.7/2.4.8. Under the hood it adds a &lt;code&gt;vite.config.js&lt;/code&gt; that Magento generates, uses &lt;code&gt;@vitejs/plugin-vue&lt;/code&gt; and Vite's core bundling to handle your theme's CSS and JS, and maps the translated/processed output back into &lt;code&gt;pub/static&lt;/code&gt; so the rest of Magento (layout XML, templates, RequireJS loading) keeps working.&lt;/p&gt;

&lt;p&gt;The key idea: instead of Magento's Grunt-driven task list, Vite does one clean compile pass with esbuild (for transforms) and Rollup (for bundling). For most themes the result is significantly faster builds and a much nicer developer experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enabling the Vite Build Path
&lt;/h2&gt;

&lt;p&gt;Because the feature is experimental, enabling it requires a few explicit steps rather than a &lt;code&gt;bin/magento setup:upgrade&lt;/code&gt; magic switch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Ensure you have Node and the Vite toolchain available in your environment&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; vite @vitejs/plugin-vue

&lt;span class="c"&gt;# 2. Generate the Vite config for your theme&lt;/span&gt;
bin/magento config:set dev/front_end_development_workflow/type vite
bin/magento config:set dev/static/sign 0

&lt;span class="c"&gt;# 3. Re-run the app config import so the config takes effect&lt;/span&gt;
bin/magento app:config:import

&lt;span class="c"&gt;# 4. Generate the vite.config.js files&lt;/span&gt;
bin/magento setup:config:vite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dev/front_end_development_workflow/type&lt;/code&gt; config value is what flips Magento from the legacy Grunt workflow to the Vite workflow. You'll also want to disable the static-content signature for local/dev work (&lt;code&gt;dev/static/sign 0&lt;/code&gt;) because Vite's dev server handles that part differently.&lt;/p&gt;

&lt;p&gt;After generating the config, you can run the Vite build directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Production build for your theme&lt;/span&gt;
vite build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for development, start the Vite dev server, which gives you instant feedback via HMR instead of waiting on a full deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What Actually Gets Faster
&lt;/h2&gt;

&lt;p&gt;The measurable wins come in a few places:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Build time
&lt;/h3&gt;

&lt;p&gt;esbuild compiles TypeScript/JavaScript at a fraction of the time of the old toolchain, and the single-pass nature of Vite avoids the repeated file copying and processing of the legacy chain. On large theme codebases, developers commonly see build times drop from minutes to seconds — which matters for both your CI deploy pipeline and daily developer iteration.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Development feedback loop
&lt;/h3&gt;

&lt;p&gt;The Vite dev server with HMR is the single biggest quality-of-life improvement. A developer editing a &lt;code&gt;.scss&lt;/code&gt; file sees the change in the browser almost instantly, without a full static deploy. That removes a constant incentive to skip testing or rush deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Bundle output
&lt;/h3&gt;

&lt;p&gt;Rollup produces tree-shaken, minified production bundles. If your theme's JavaScript was pulling in more than it used, you'll often end up with smaller final bundles and better first-load performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Important Caveats Before You Adopt It
&lt;/h2&gt;

&lt;p&gt;Being experimental, the Vite path is &lt;strong&gt;not&lt;/strong&gt; a drop-in replacement for every store yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's not the default.&lt;/strong&gt; Adobe positions it as a preview for the future direction of Magento's frontend tooling, not as a fully supported production path in 2.4.7.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compatibility is limited.&lt;/strong&gt; Third-party modules and themes that rely on the legacy Grunt/&lt;code&gt;less&lt;/code&gt;/&lt;code&gt;css-m&lt;/code&gt; pipeline, or that inject assets through the old deploy hooks, may not behave correctly under Vite. Audit your custom and third-party frontend code before switching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RequireJS interplay.&lt;/strong&gt; Magento's module-based JS loading is built around RequireJS. Vite's bundling model is different, so you need to verify your module JavaScript still loads in the right order and scopes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI changes.&lt;/strong&gt; Your deploy pipeline must now run &lt;code&gt;vite build&lt;/code&gt; (and have Node + Vite available) instead of (or alongside) &lt;code&gt;setup:static-content:deploy&lt;/code&gt;. That's a real infrastructure change, not just a flag flip.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Should You Switch?
&lt;/h2&gt;

&lt;p&gt;My honest take: &lt;strong&gt;wait unless you're building a new theme or running a 2.4.8+ store with a fully controlled frontend stack.&lt;/strong&gt; For a new custom theme with no legacy dependencies, the Vite path is an excellent, faster, more pleasant workflow worth adopting early. For a mature enterprise store running dozens of third-party modules and a heavily customized theme, the compatibility risk outweighs the build-time savings for now — but it's absolutely worth running a branch experiment to measure the difference on your own theme.&lt;/p&gt;

&lt;p&gt;Either way, this is the direction Magento's frontend tooling is heading. Familiarizing yourself with the Vite path now puts you well ahead of the curve when it becomes the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vite replaces the Grunt/&lt;code&gt;less&lt;/code&gt;/&lt;code&gt;static:deploy&lt;/code&gt; chain&lt;/strong&gt; with a modern esbuild + Rollup pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable it via &lt;code&gt;dev/front_end_development_workflow/type = vite&lt;/code&gt;&lt;/strong&gt; plus &lt;code&gt;setup:config:vite&lt;/code&gt; to generate the config.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;dev server with HMR&lt;/strong&gt; and &lt;strong&gt;drastically faster builds&lt;/strong&gt; are the biggest wins.&lt;/li&gt;
&lt;li&gt;Treat it as &lt;strong&gt;experimental&lt;/strong&gt;: audit third-party modules, adjust CI, and verify RequireJS loading before going all-in.&lt;/li&gt;
&lt;li&gt;For new, controlled themes it's a genuinely better workflow; for complex legacy stores, run a branch pilot first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Frontend build performance doesn't get as much attention as Redis or Varnish, but it's the layer your developers and your deploy pipeline touch on every single release. Modernizing it with Vite is one of the few changes that improves both developer velocity &lt;em&gt;and&lt;/em&gt; the code you ship.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
  </channel>
</rss>
