<?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 Linux Server Tuning: Kernel, Filesystem &amp; I/O Settings That Actually Matter</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Sun, 23 Aug 2026 09:01:45 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-linux-server-tuning-kernel-filesystem-io-settings-that-actually-matter-481c</link>
      <guid>https://dev.to/magevanta/magento-2-linux-server-tuning-kernel-filesystem-io-settings-that-actually-matter-481c</guid>
      <description>&lt;p&gt;Most Magento 2 performance work stops at the application layer: PHP-FPM pools, Nginx config, MySQL buffers, Redis, Varnish. That's where the visible wins are. But underneath all of it sits the operating system, and on a stock Ubuntu or Debian install it's running kernel, filesystem and I/O defaults that were never designed for an application that reads and writes thousands of small files and pounds a database with mixed random I/O every second.&lt;/p&gt;

&lt;p&gt;This guide covers the OS-level settings that actually move the needle for Magento 2: filesystem mount options, I/O scheduler, memory tuning (swappiness, dirty pages, Transparent Huge Pages), CPU governor, file descriptor limits and the &lt;code&gt;tuned&lt;/code&gt; profile. Everything is verifiable, and most changes are two lines in &lt;code&gt;sysctl.conf&lt;/code&gt; plus a remount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the OS layer matters for Magento
&lt;/h2&gt;

&lt;p&gt;Magento 2's workload profile is specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Thousands of small files.&lt;/strong&gt; &lt;code&gt;pub/static&lt;/code&gt;, &lt;code&gt;var/view_preprocessed&lt;/code&gt;, &lt;code&gt;generated/&lt;/code&gt; and &lt;code&gt;pub/media&lt;/code&gt; add up to hundreds of thousands of inodes. Every static content deploy rewrites tens of thousands of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixed random I/O on MySQL.&lt;/strong&gt; The &lt;code&gt;catalog_product_index_price&lt;/code&gt; rebuild, order grid indexing and log cleanup all do heavy sequential-ish scans, while checkout does small random reads. Your storage handles both at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bursty cache writes.&lt;/strong&gt; Redis writes, session files (if filesystem-backed), Varnish cache — all bursty, all latency-sensitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page cache dependency.&lt;/strong&gt; A single framework-level cache miss cascades into dozens of MySQL round-trips. The OS can't fix that, but it decides how fast those disk-backed misses resolve.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your stack is already well-tuned and your bottleneck is still I/O or latency spikes under load, the OS layer is the next place to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Filesystem: ext4 vs XFS and mount options
&lt;/h2&gt;

&lt;p&gt;The filesystem choice matters less than most people think — modern ext4 and XFS both handle Magento workloads fine. What matters more is &lt;em&gt;how&lt;/em&gt; they're mounted.&lt;/p&gt;

&lt;p&gt;Minimal mount options that are safe on ext4 for a Magento server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;/&lt;span class="n"&gt;dev&lt;/span&gt;/&lt;span class="n"&gt;sdb1&lt;/span&gt;  /&lt;span class="n"&gt;var&lt;/span&gt;/&lt;span class="n"&gt;www&lt;/span&gt;  &lt;span class="n"&gt;ext4&lt;/span&gt;  &lt;span class="n"&gt;defaults&lt;/span&gt;,&lt;span class="n"&gt;noatime&lt;/span&gt;,&lt;span class="n"&gt;nodiratime&lt;/span&gt;,&lt;span class="n"&gt;errors&lt;/span&gt;=&lt;span class="n"&gt;remount&lt;/span&gt;-&lt;span class="n"&gt;ro&lt;/span&gt;  &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;noatime&lt;/code&gt;&lt;/strong&gt; — the big one. Every file read updates the atime (access time) by default, which means a metadata write on &lt;em&gt;every&lt;/em&gt; read. Magento reads the same static files and opcache-scanned PHP files thousands of times a day. Disabling atime eliminates a whole class of unnecessary writes. &lt;code&gt;nodiratime&lt;/code&gt; is redundant on modern kernels when &lt;code&gt;noatime&lt;/code&gt; is set, but harmless to include.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;commit=60&lt;/code&gt;&lt;/strong&gt; (ext4) — the journal commit interval. Default is 5 seconds; raising it to 30–60 reduces journal write pressure on busy data volumes. Only for non-critical data partitions; don't do this on a volume whose recent writes you'd hate to lose.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;discard&lt;/code&gt; vs &lt;code&gt;fstrim&lt;/code&gt;&lt;/strong&gt; — on SSDs/NVMe, periodic &lt;code&gt;fstrim&lt;/code&gt; (via cron or systemd timer) is safer than the &lt;code&gt;discard&lt;/code&gt; mount option, which can cause I/O stalls on some controllers. Use &lt;code&gt;fstrim -av&lt;/code&gt; weekly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Verify:&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;mount | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'var/www|pub'&lt;/span&gt;
&lt;span class="c"&gt;# "noatime" should be in the options list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A note on &lt;code&gt;var/&lt;/code&gt; and &lt;code&gt;generated/&lt;/code&gt;:&lt;/strong&gt; some teams put &lt;code&gt;var/cache&lt;/code&gt;, &lt;code&gt;var/session&lt;/code&gt; or &lt;code&gt;generated/&lt;/code&gt; on &lt;code&gt;tmpfs&lt;/code&gt; (RAM disk) to eliminate disk I/O entirely. This works and is fast, but it's volatile: a reboot empties them, so &lt;code&gt;var/cache&lt;/code&gt; and &lt;code&gt;generated/&lt;/code&gt; must be rebuildable in your deploy (they are — cache is warmable, generated code comes from &lt;code&gt;bin/magento setup:di:compile&lt;/code&gt;). Never put anything irreplaceable there, and size it carefully (a &lt;code&gt;generated/&lt;/code&gt; dir can exceed 1–2 GB on big catalogs). For most setups, a fast NVMe with &lt;code&gt;noatime&lt;/code&gt; is the safer 90% of the win.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. I/O scheduler: know what you're running on
&lt;/h2&gt;

&lt;p&gt;The I/O scheduler decides how requests are queued to the disk. Since kernel 5.x on NVMe, the default is &lt;code&gt;none&lt;/code&gt; (noop), which is correct — NVMe devices with deep hardware queues don't need software scheduling. On SATA SSDs and HDDs, &lt;code&gt;mq-deadline&lt;/code&gt; is the right choice.&lt;/p&gt;

&lt;p&gt;Check with:&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="nb"&gt;cat&lt;/span&gt; /sys/block/sda/queue/scheduler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it shows &lt;code&gt;[none]&lt;/code&gt; on a SATA SSD or HDD, switch to &lt;code&gt;mq-deadline&lt;/code&gt; at boot via a udev rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# /etc/udev/rules.d/60-iosched.rules
ACTION=="add|change", KERNEL=="sd[a-z]", ATTR{queue/scheduler}="mq-deadline"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What &lt;em&gt;really&lt;/em&gt; matters for Magento is &lt;strong&gt;not letting the scheduler starve your database&lt;/strong&gt;. If MySQL, Redis and the web server share one disk, heavy batch jobs (reindex, log cleanup, static content deploy) can saturate the queue and push checkout latency through the roof. Two mitigations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Run batch jobs with &lt;code&gt;ionice&lt;/code&gt;: &lt;code&gt;ionice -c 2 -n 7 bin/magento indexer:reindex&lt;/code&gt; — best-effort class, lowest priority, so the queue still services foreground reads.&lt;/li&gt;
&lt;li&gt;If you can, split storage: database on one volume, &lt;code&gt;pub/media&lt;/code&gt; + &lt;code&gt;var/&lt;/code&gt; on another. This is the single most effective I/O isolation you can do on a single server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Memory: swappiness, dirty pages and THP
&lt;/h2&gt;

&lt;p&gt;Three settings that are consistently wrong on stock installs for Magento + MySQL servers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;vm.swappiness&lt;/code&gt;&lt;/strong&gt; — controls how eagerly the kernel swaps anonymous memory. Default is 60, which is far too aggressive for a server with a large page cache. For database servers, &lt;code&gt;vm.swappiness = 10&lt;/code&gt; is the classic recommendation; for Magento with Redis caching, &lt;code&gt;10&lt;/code&gt; is a good middle ground (Redis' own &lt;code&gt;maxmemory&lt;/code&gt; policy handles eviction, and you don't want Redis pages swapped out). On systems with plenty of RAM, 0–10.&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;# /etc/sysctl.d/99-magento.conf&lt;/span&gt;
vm.swappiness &lt;span class="o"&gt;=&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dirty page writeback&lt;/strong&gt; — when the kernel accumulates too many dirty pages, it flushes them in bursts, causing I/O stalls that show up as MySQL latency spikes. A known-good pairing for DB servers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vm.dirty_ratio &lt;span class="o"&gt;=&lt;/span&gt; 15
vm.dirty_background_ratio &lt;span class="o"&gt;=&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dirty_background_ratio&lt;/code&gt; starts background writeback at 5% dirty memory, &lt;code&gt;dirty_ratio&lt;/code&gt; forces synchronous flush at 15%. The defaults (10/20 on many distros) let dirtiness climb higher before acting, producing bigger, nastier bursts. On a server with 64 GB RAM these numbers mean MySQL's double-write buffer and redo log flushes stay smooth instead of queuing behind a wall of page-cache writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transparent Huge Pages (THP)&lt;/strong&gt; — the single most famous OS-level fix in the MySQL world. THP is enabled by default (&lt;code&gt;always&lt;/code&gt;), and while it helps some workloads, for MySQL it's documented to cause severe latency issues: memory is allocated in 2 MB huge pages, and during defragmentation the kernel stalls threads waiting for page compaction. Every major distro's MySQL docs say the same thing: set it to &lt;code&gt;madvise&lt;/code&gt; or &lt;code&gt;never&lt;/code&gt;.&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="nb"&gt;echo &lt;/span&gt;never &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/kernel/mm/transparent_hugepage/enabled
&lt;span class="nb"&gt;echo &lt;/span&gt;never &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/kernel/mm/transparent_hugepage/defrag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make it persistent:&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;# /etc/systemd/system/disable-thp.service&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;Unit]
&lt;span class="nv"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Disable Transparent Huge Pages
&lt;span class="nv"&gt;After&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;multi-user.target

&lt;span class="o"&gt;[&lt;/span&gt;Service]
&lt;span class="nv"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;oneshot
&lt;span class="nv"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo never &amp;gt; /sys/kernel/mm/transparent_hugepage/enabled &amp;amp;&amp;amp; echo never &amp;gt; /sys/kernel/mm/transparent_hugepage/defrag'&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt;Install]
&lt;span class="nv"&gt;WantedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify: &lt;code&gt;cat /sys/kernel/mm/transparent_hugepage/enabled&lt;/code&gt; should show &lt;code&gt;[never]&lt;/code&gt; or &lt;code&gt;[madvise]&lt;/code&gt;. This alone has fixed "random" MySQL latency spikes on more Magento servers than I can count.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. CPU governor: stop letting the kernel downclock your store
&lt;/h2&gt;

&lt;p&gt;On bare metal and VPS with exposed CPU frequency scaling, the default &lt;code&gt;powersave&lt;/code&gt;/&lt;code&gt;ondemand&lt;/code&gt; governor downclocks cores aggressively, which shows up as slow PHP execution — exactly the symptom people blame on "Magento being slow." For a server, set it to &lt;code&gt;performance&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cpupower frequency-set &lt;span class="nt"&gt;-g&lt;/span&gt; performance   &lt;span class="c"&gt;# or:&lt;/span&gt;
&lt;span class="nb"&gt;echo &lt;/span&gt;performance &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist via &lt;code&gt;tuned&lt;/code&gt; (below) or &lt;code&gt;cpupower.service&lt;/code&gt;. Note: on most cloud VPS (AWS, DigitalOcean, Hetzner) the governor is not exposed or already pinned — check &lt;code&gt;cpupower frequency-info&lt;/code&gt; and skip this if it's not settable.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. File descriptor limits: the silent 1024-cap
&lt;/h2&gt;

&lt;p&gt;PHP-FPM, Nginx, Elasticsearch/OpenSearch, Redis and MySQL all need more than the default 1024 soft file descriptor limit when the store is busy. Symptoms of hitting it: "Too many open files" in &lt;code&gt;php-fpm.log&lt;/code&gt;, MySQL &lt;code&gt;Can't open file&lt;/code&gt; errors, ES red logs for no obvious reason.&lt;/p&gt;

&lt;p&gt;Raise the &lt;em&gt;system&lt;/em&gt; limit and per-service limits:&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;# /etc/sysctl.d/99-magento.conf&lt;/span&gt;
fs.file-max &lt;span class="o"&gt;=&lt;/span&gt; 2097152
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in each systemd unit (PHP-FPM, Elasticsearch, Redis, MySQL), add:&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;[Service]&lt;/span&gt;
&lt;span class="py"&gt;LimitNOFILE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;65535&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of those settings that never shows up in app-level profiling but is a classic cause of "random" errors at peak traffic. If you see these errors &lt;em&gt;only&lt;/em&gt; during Black Friday / flash-sale spikes, check this first.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Just use tuned: &lt;code&gt;throughput-performance&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Instead of hand-rolling all of this per distro flavor, install &lt;code&gt;tuned&lt;/code&gt; and apply the &lt;code&gt;throughput-performance&lt;/code&gt; profile, which sets the sane defaults for servers (governor to performance, swappiness to 10, THP to madvise, noatime-ish behavior):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;tuned
tuned-adm profile throughput-performance
tuned-adm active   &lt;span class="c"&gt;# verify&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You still want to explicitly set &lt;code&gt;vm.dirty_*&lt;/code&gt; and disable THP for MySQL yourself (the profile only sets THP to &lt;code&gt;madvise&lt;/code&gt;; many DBAs prefer &lt;code&gt;never&lt;/code&gt;), but &lt;code&gt;tuned&lt;/code&gt; gives you a solid, persistent baseline in one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. What to measure after applying
&lt;/h2&gt;

&lt;p&gt;Every change here should be justified by a before/after. The tools that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;iostat -x 2&lt;/code&gt;&lt;/strong&gt; — look at &lt;code&gt;%util&lt;/code&gt;, &lt;code&gt;await&lt;/code&gt;, &lt;code&gt;svctm&lt;/code&gt;. If &lt;code&gt;await&lt;/code&gt; is high on MySQL's volume during reindex, your I/O layer is the bottleneck, not the query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vmstat 2&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;si&lt;/code&gt;/&lt;code&gt;so&lt;/code&gt; column (swap in/out) should be near zero. If you see steady swapping, you're undersized on RAM or swappiness is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL &lt;code&gt;SHOW GLOBAL STATUS&lt;/code&gt; for &lt;code&gt;Innodb_buffer_pool_wait_free&lt;/code&gt;&lt;/strong&gt; — if this counter climbs, InnoDB is waiting for page flushes under memory pressure; dirty-page tuning and buffer pool sizing are your lever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dmesg&lt;/code&gt; / &lt;code&gt;journalctl -k&lt;/code&gt; for "stall" or THP compaction messages&lt;/strong&gt; — smoking gun for THP-related latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apply changes one at a time, keep the sysctl file small and commented, and re-run your load test (k6, siege, or a replay of your slow-query log) after each change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;p&gt;Here's the full set, condensed — safe on any Ubuntu/Debian Magento 2 server:&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;# /etc/sysctl.d/99-magento.conf&lt;/span&gt;
vm.swappiness &lt;span class="o"&gt;=&lt;/span&gt; 10
vm.dirty_ratio &lt;span class="o"&gt;=&lt;/span&gt; 15
vm.dirty_background_ratio &lt;span class="o"&gt;=&lt;/span&gt; 5
fs.file-max &lt;span class="o"&gt;=&lt;/span&gt; 2097152
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Remount data partitions with &lt;code&gt;noatime&lt;/code&gt; (verify with &lt;code&gt;mount&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;I/O scheduler: &lt;code&gt;none&lt;/code&gt; on NVMe, &lt;code&gt;mq-deadline&lt;/code&gt; on SATA/HDD.&lt;/li&gt;
&lt;li&gt;THP → &lt;code&gt;never&lt;/code&gt;, persisted via systemd unit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tuned-adm profile throughput-performance&lt;/code&gt; (+ CPU governor to &lt;code&gt;performance&lt;/code&gt; where exposed).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;LimitNOFILE=65535&lt;/code&gt; on PHP-FPM, ES, Redis, MySQL systemd units.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ionice&lt;/code&gt; your reindex/cron batch jobs; separate MySQL from media/var volumes if possible.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Skip-worthy:&lt;/strong&gt; swappiness below 10 (no benefit, hurts page cache), &lt;code&gt;noatime&lt;/code&gt; on &lt;code&gt;commit=0&lt;/code&gt; journal tricks, aggressive &lt;code&gt;vm.dirty_expire_centisecs&lt;/code&gt; micro-tuning, and any kernel compile-level "optimizations" you found in a 2015 blog post. The list above is the tested 90%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest bottom line
&lt;/h2&gt;

&lt;p&gt;The OS layer is where the last 5–10% of a Magento 2 server's performance lives — but only &lt;em&gt;after&lt;/em&gt; the app layer is done. If your MySQL slow-query log is full, your FPM &lt;code&gt;pm.max_children&lt;/code&gt; is wrong, or your Varnish hit rate is under 90%, fix those first. When you've done that and the server still feels "laggy under load" without an obvious app-level cause, the settings above — especially THP, swappiness/dirty ratios and I/O scheduler — are where the hidden latency actually lives. They're free, they're reversible, and unlike most Magento "performance tips," they're verifiable with three commands.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
      <category>linux</category>
      <category>devops</category>
    </item>
    <item>
      <title>Magento 2 Product Reviews Performance: The Hidden Cron &amp; N+1 Bottleneck</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Sat, 22 Aug 2026 09:03:35 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-product-reviews-performance-the-hidden-cron-n1-bottleneck-4bh5</link>
      <guid>https://dev.to/magevanta/magento-2-product-reviews-performance-the-hidden-cron-n1-bottleneck-4bh5</guid>
      <description>&lt;p&gt;Product reviews look innocent. A star rating in the listing, a list of comments on the product page, an admin grid to moderate them. What could possibly be slow about that?&lt;/p&gt;

&lt;p&gt;Plenty. Reviews are one of those Magento subsystems where the &lt;em&gt;visible&lt;/em&gt; surface is small but the &lt;em&gt;hidden&lt;/em&gt; machinery is disproportionate: a full aggregation cron that recomputes every rating from scratch every 15 minutes, per-product summary queries that turn a 30-product listing into 30 extra SQL round-trips, and an admin grid that joins product names across the whole catalog. On stores with a few hundred thousand reviews — which takes surprisingly little time to accumulate — reviews quietly become one of the top five slow things in your stack.&lt;/p&gt;

&lt;p&gt;This guide walks through the three real bottlenecks (the cron, the N+1, the grid), what to measure, and the fixes that actually work.&lt;/p&gt;

&lt;h2&gt;
  
  
  How reviews are stored
&lt;/h2&gt;

&lt;p&gt;Before optimizing anything, know the tables. Magento 2 spreads a single "review" across four tables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;review&lt;/code&gt;&lt;/strong&gt; — the review itself: &lt;code&gt;entity_pk_value&lt;/code&gt; (product id), &lt;code&gt;entity_id&lt;/code&gt; (review entity type), &lt;code&gt;status_id&lt;/code&gt; (1 = approved, 2 = pending, 3 = not approved), &lt;code&gt;created_at&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;review_detail&lt;/code&gt;&lt;/strong&gt; — the localized content: title, detail text, nickname, customer id, per store view. This is the table that grows fastest on multi-store setups, because a review gets &lt;em&gt;one row per store view&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;rating_option_vote&lt;/code&gt;&lt;/strong&gt; — the raw star votes, one row per rating per review. &lt;code&gt;percent&lt;/code&gt;, &lt;code&gt;value&lt;/code&gt;, &lt;code&gt;review_id&lt;/code&gt;, &lt;code&gt;entity_pk_value&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;review_entity_summary&lt;/code&gt;&lt;/strong&gt; — the &lt;em&gt;pre-aggregated&lt;/em&gt; numbers per product per store: &lt;code&gt;reviews_count&lt;/code&gt;, &lt;code&gt;ratings_summary&lt;/code&gt;. This is what the storefront actually reads, so it's the table that must stay fast.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design is fine — the problem is how the aggregated table gets maintained.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-minute aggregation cron (the real killer)
&lt;/h2&gt;

&lt;p&gt;Magento ships a cron job called &lt;strong&gt;&lt;code&gt;aggregate_reviews&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;Magento\Review\Cron\AggregateReviews&lt;/code&gt;), scheduled &lt;strong&gt;every 15 minutes&lt;/strong&gt; in core. For every store view, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deletes all rows from &lt;code&gt;review_entity_summary&lt;/code&gt; and &lt;code&gt;rating_option_vote_aggregated&lt;/code&gt; for that store,&lt;/li&gt;
&lt;li&gt;Recomputes them from scratch by joining &lt;code&gt;rating_option_vote&lt;/code&gt; against &lt;code&gt;review&lt;/code&gt; (filtered on approved status),&lt;/li&gt;
&lt;li&gt;Re-inserts the results.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's a full recompute of &lt;em&gt;every rating in the store&lt;/em&gt; — not an incremental update. On a store with 200k reviews and 500k votes, this single job is a multi-hundred-million-row scan executed four times an hour, and it shows up in your slow-query log as a monster &lt;code&gt;INSERT ... SELECT ... GROUP BY&lt;/code&gt; that runs for minutes. It also fires during business hours, right when you'd rather not have a heavyweight aggregating query competing with checkout traffic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check if this is you:&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="c1"&gt;-- longest aggregate runs in the last 7 days&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;job_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduled_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;executed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finished_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;TIMESTAMPDIFF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SECOND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;executed_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;finished_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cron_schedule&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;job_code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'aggregate_reviews'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;executed_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see runs of minutes, or overlaps (the next run starting before the previous finished — look for &lt;code&gt;status = 'running'&lt;/code&gt; while a new row is scheduled), the job is a problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixes, in order of effort:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Move it off-peak.&lt;/strong&gt; The schedule lives in &lt;code&gt;app/code/Magento/Review/etc/crontab.xml&lt;/code&gt;. Don't patch core — override it in a small custom module or via a deployment script that rewrites the config. Moving the job from &lt;code&gt;*/15 * * * *&lt;/code&gt; to e.g. &lt;code&gt;5 */1 * * *&lt;/code&gt; (hourly) or even daily at 03:00 changes nothing for the storefront: &lt;code&gt;review_entity_summary&lt;/code&gt; is only read after a cache refresh anyway, and a couple of hours of staleness on star ratings is invisible to customers. What you lose is nothing; what you gain is 90+ fewer full recomputes per day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Batch it.&lt;/strong&gt; If even one daily run is too heavy, write your own aggregator that processes products in chunks (e.g. 5,000 products per transaction) instead of one giant &lt;code&gt;INSERT ... SELECT&lt;/code&gt;. Same math, no long locks, no temp-table spill to disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Reduce contention.&lt;/strong&gt; Make sure the job runs in a cron pool with a dedicated PHP-FPM/CLI environment and enough &lt;code&gt;tmp_table_size&lt;/code&gt;/&lt;code&gt;max_heap_table_size&lt;/code&gt; — the aggregation is a textbook "create temporary table on disk" query when MySQL's in-memory temp table limit is too low.&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 on listings and product pages
&lt;/h2&gt;

&lt;p&gt;The storefront reads &lt;code&gt;review_entity_summary&lt;/code&gt;, which is the good news — it's a small, indexed table. The bad news: many themes render star ratings in &lt;em&gt;listings&lt;/em&gt; (category pages, related products, recently viewed, wishlist widgets), and the core &lt;code&gt;Magento\Review\Block\Product\ReviewRenderer&lt;/code&gt; loads the summary &lt;strong&gt;per product&lt;/strong&gt;. Thirty products → thirty &lt;code&gt;SELECT ... FROM review_entity_summary WHERE entity_pk_value = ?&lt;/code&gt; queries per page view. Add a "recently viewed" strip with ratings and you're at 45–60 queries before anyone even looks at the product.&lt;/p&gt;

&lt;p&gt;Worse is the product page with a &lt;em&gt;review list&lt;/em&gt;: the toolbar, pagination count and each review's vote data all trip additional lookups on &lt;code&gt;review&lt;/code&gt;, &lt;code&gt;review_detail&lt;/code&gt; and &lt;code&gt;rating_option_vote&lt;/code&gt;. On a product with 500 reviews you're now loading a lot of rows for a tab most visitors never open. (Core paginates at 10 per page — good — but the &lt;em&gt;count&lt;/em&gt; query and the vote joins still run.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is preloading, not caching:&lt;/strong&gt;&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;// In your listing block: collect product ids first, then load ALL summaries in ONE query&lt;/span&gt;
&lt;span class="nv"&gt;$productIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$products&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="cd"&gt;/** @var \Magento\Review\Model\ResourceModel\Review\Summary\Collection $summaries */&lt;/span&gt;
&lt;span class="nv"&gt;$summaries&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;summaryCollectionFactory&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;$summaries&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addEntityFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productIds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// batch: WHERE entity_pk_value IN (...)&lt;/span&gt;
          &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStoreFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$storeId&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="nv"&gt;$summaryMap&lt;/span&gt; &lt;span class="o"&gt;=&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;$summaries&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$summaryMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEntityPkValue&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Then feed each renderer instead of letting it query&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="cd"&gt;/** @var \Magento\Review\Block\Product\ReviewRenderer $renderer */&lt;/span&gt;
    &lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$summaryMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$renderer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setProduct&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="nv"&gt;$renderer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setRatingSummary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRatingSummary&lt;/span&gt;&lt;span class="p"&gt;()&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="nv"&gt;$renderer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setReviewsCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$summary&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;$summary&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getReviewsCount&lt;/span&gt;&lt;span class="p"&gt;()&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;echo&lt;/span&gt; &lt;span class="nv"&gt;$renderer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toHtml&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;One query for the whole page instead of one per product. This is the same pattern that fixes related products, wishlist and recently viewed — the block is the bottleneck, not the table.&lt;/p&gt;

&lt;p&gt;For the review &lt;em&gt;list&lt;/em&gt; on the product page: keep core's pagination, but lazy-load the list itself (an AJAX "load reviews" button) if your product pages carry hundreds of reviews. The star summary in the header is the part customers actually see; the full list can arrive on demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slow admin grid
&lt;/h2&gt;

&lt;p&gt;The admin "Reviews" grid (&lt;code&gt;Magento\Review\Model\ResourceModel\Review\Product\Collection&lt;/code&gt;) joins the review tables against &lt;code&gt;catalog_product_entity&lt;/code&gt; plus a store-scoped product name lookup. With hundreds of thousands of reviews, three things degrade:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unfiltered grid loads&lt;/strong&gt; — the count and first-page queries scan the whole &lt;code&gt;review&lt;/code&gt; table; the &lt;code&gt;entity_pk_value&lt;/code&gt; column is not meaningfully selective when the grid has no filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The product-name join&lt;/strong&gt; — resolving names per store across a big catalog makes every grid refresh expensive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing composite indexes&lt;/strong&gt; — single-column indexes don't help &lt;code&gt;WHERE status_id = ? AND entity_pk_value IN (...)&lt;/code&gt; style lookups.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Indexes worth adding (after testing on staging):&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;review&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IDX_REVIEW_STATUS_ENTITY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;entity_pk_value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IDX_REVIEW_CREATED_AT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;review_detail&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IDX_REVIEW_DETAIL_STORE&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;store_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;rating_option_vote&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IDX_RATING_VOTE_REVIEW&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;review_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also: encourage your moderators to &lt;em&gt;filter&lt;/em&gt; (pending status, date range, product) instead of paging through the full grid, and run a periodic cleanup of old &lt;code&gt;not approved&lt;/code&gt; reviews to keep the table from growing with spam that nobody will ever moderate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache behavior: review bursts are cache churn
&lt;/h2&gt;

&lt;p&gt;A submitted review invalidates the product page's full-page cache tag (&lt;code&gt;catalog_product_{id}&lt;/code&gt;) — Magento does this deliberately so the new rating shows up. That's correct behavior, but it has a scaling consequence: during a marketing push that generates hundreds of reviews per hour on a few hot products, those products' FPC entries keep getting invalidated and re-rendered, and &lt;em&gt;every&lt;/em&gt; visitor pays for the re-render until the next hit warms it.&lt;/p&gt;

&lt;p&gt;Mitigation options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accept slight staleness: reviews can lag a few minutes; the aggregation cron already introduces the same lag.&lt;/li&gt;
&lt;li&gt;If bursts are a real pattern, an inline/ESI-style fragment for the review summary keeps the rest of the page cached while only the rating fragment re-renders.&lt;/li&gt;
&lt;li&gt;Watch your Varnish hit-ratio dashboard during review-heavy campaigns — if the hot-product pages drop out of cache repeatedly, that's the symptom.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure first:&lt;/strong&gt; &lt;code&gt;cron_schedule&lt;/code&gt; durations for &lt;code&gt;aggregate_reviews&lt;/code&gt;, query count via the profiler on a category page with ratings, and the admin grid's load time unfiltered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reschedule the aggregation cron&lt;/strong&gt; off-peak (hourly or daily) — zero storefront impact, big DB relief.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch the aggregation&lt;/strong&gt; if a single run still takes minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preload summaries in listings&lt;/strong&gt; with one batched &lt;code&gt;addEntityFilter()&lt;/code&gt; collection instead of per-product queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-load long review lists&lt;/strong&gt; on product pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the composite indexes&lt;/strong&gt; for status/entity and vote lookups, after staging tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the admin grid filtered&lt;/strong&gt; and clean out never-approved spam rows.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reviews are a social-proof feature with a hidden performance tax. None of these fixes change what customers see — they change what your database does four times an hour. That's the best kind of optimization: invisible, and worth more than a hundred milliseconds of TTFB tricks.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
      <category>php</category>
    </item>
    <item>
      <title>Magento 2 PHP 8.4 Upgrade: Real Performance Gains &amp; a Safe Migration Checklist</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Fri, 21 Aug 2026 09:02:58 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-php-84-upgrade-real-performance-gains-a-safe-migration-checklist-2e49</link>
      <guid>https://dev.to/magevanta/magento-2-php-84-upgrade-real-performance-gains-a-safe-migration-checklist-2e49</guid>
      <description>&lt;p&gt;Every Magento performance audit I do ends the same way: we spend weeks optimizing a custom module, fiddling with Varnish TTLs, shaving off a few milliseconds of TTFB — and the single biggest, cheapest win is still sitting untouched in &lt;code&gt;php -v&lt;/code&gt;. Most stores run PHP 8.1 or 8.2 not because they need to, but because nobody scheduled the upgrade. Meanwhile each PHP minor release ships a few percent of free performance, better opcache behavior and lower memory usage.&lt;/p&gt;

&lt;p&gt;This guide covers what PHP 8.4 actually gives a Magento 2 store, which Magento versions support it, and a pragmatic checklist to upgrade without burning a weekend fighting deprecation warnings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PHP 8.4 delivers (the honest version)
&lt;/h2&gt;

&lt;p&gt;Before the features, the number that matters: measured on CMS-style workloads (which is what Magento is — object-heavy, I/O-bound PHP), PHP 8.4 is roughly &lt;strong&gt;5–10% faster than 8.3&lt;/strong&gt;, which itself was ~10% faster than 8.2, which was ~10–20% faster than 8.1 depending on the workload. Stacked up: &lt;strong&gt;8.1 → 8.4 is often a 25–35% throughput improvement on the same hardware.&lt;/strong&gt; No Redis tuning, no query optimization, no code changes — just a runtime swap.&lt;/p&gt;

&lt;p&gt;Where does that come from?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opcache improvements&lt;/strong&gt; — better JIT tuning, faster hash tables, more aggressive opcode optimizations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduced memory usage&lt;/strong&gt; — PHP 8.4 continues the trend of lower per-request allocation, which matters more than raw CPU for Magento: FPM workers that use less memory per request can each serve more concurrent requests before &lt;code&gt;pm.max_children&lt;/code&gt; becomes the wall.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Faster autoloading and startup&lt;/strong&gt; — a meaningful slice of every Magento request is class loading and bootstrapping; the runtime-level gains compound with opcache preloading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental garbage collector&lt;/strong&gt; (since 8.3, still relevant) — cycle collection no longer pauses the whole request, which smooths out latency spikes on long-running cron jobs and imports.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest caveat: don't expect a 30% TTFB drop on a cached page. If the page is served from Varnish or full-page cache, PHP barely runs. The gains show up on &lt;strong&gt;uncached pages, checkout (which is deliberately non-cacheable), admin operations, complex API calls, reindexing, imports and concurrent traffic&lt;/strong&gt;. For a store with a warm cache, the win is capacity, not latency: the same server handles more checkout load during a flash sale. That's often worth more than a faster homepage.&lt;/p&gt;

&lt;h2&gt;
  
  
  JIT: why Magento mostly ignores it
&lt;/h2&gt;

&lt;p&gt;PHP 8.0 introduced JIT, and every blog post since has asked whether Magento should enable it. Short answer: &lt;strong&gt;no, and 8.4 doesn't change that.&lt;/strong&gt; JIT shines on CPU-bound pure-computation loops. Magento's request lifecycle is dominated by I/O (MySQL, Redis, filesystem, network) and object instantiation — precisely the workload where JIT overhead (memory for compiled traces, more complex opcache state) can even make things marginally &lt;em&gt;slower&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The exception: if you run heavy CPU-bound PHP workloads — big product imports with lots of arithmetic, custom report generation, complex rule matching — you can A/B test &lt;code&gt;opcache.jit=tracing&lt;/code&gt; in a staging environment and measure with a stopwatch rather than vibes. Default (off in FPM, and not enabled by Magento's config) remains the right production choice for the vast majority of stores.&lt;/p&gt;

&lt;p&gt;What helps instead:&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;; php.ini — the boring wins that actually matter for Magento
&lt;/span&gt;&lt;span class="py"&gt;opcache.enable&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;opcache.memory_consumption&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;512&lt;/span&gt;
&lt;span class="py"&gt;opcache.validate_timestamps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0        ; in production only!&lt;/span&gt;
&lt;span class="py"&gt;opcache.revalidate_freq&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;span class="py"&gt;opcache.interned_strings_buffer&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;32   ; PHP 8.4 handles interned strings more efficiently&lt;/span&gt;
&lt;span class="py"&gt;realpath_cache_size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;4096K            ; filesystem-heavy Magento boot loves this&lt;/span&gt;
&lt;span class="py"&gt;realpath_cache_ttl&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Which Magento supports PHP 8.4
&lt;/h2&gt;

&lt;p&gt;Compatibility matrix, as of writing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Magento 2.4.7 and earlier&lt;/strong&gt;: PHP 8.2 max. Do not force 8.4 on these — core code (and most extensions) will throw deprecations and fatals in places.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Magento Open Source / Adobe Commerce 2.4.8&lt;/strong&gt; (April 2025): first release with &lt;strong&gt;official PHP 8.4 support&lt;/strong&gt; (also the release that moved to MariaDB 11.4 and OpenSearch 2.x defaults). EOL April 2028.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2.4.8-p1 and later patch releases&lt;/strong&gt;: PHP 8.4 remains supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the upgrade path is really: &lt;strong&gt;get to 2.4.8+, then move PHP&lt;/strong&gt;. If you're still on 2.4.6-p5 or earlier, the PHP upgrade is a side effect of the Magento upgrade, and you should treat both as one project with a proper staging phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upgrade checklist
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Know what you're running
&lt;/h3&gt;

&lt;p&gt;Before touching anything, inventory the runtime surface: PHP version on each node, FPM pool config, which web server talks to FPM, and the PHP binary used by &lt;code&gt;bin/magento&lt;/code&gt; cron. Stores with multiple servers frequently differ between CLI and FPM — that's a silent source of "it works on my machine" disasters.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Fix composer constraints
&lt;/h3&gt;

&lt;p&gt;The platform requirements live in &lt;code&gt;composer.json&lt;/code&gt; and the lock file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"platform"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"php"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8.4.0"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set the platform to the &lt;em&gt;target&lt;/em&gt; PHP version, run &lt;code&gt;composer update --lock&lt;/code&gt;, and let Composer tell you which packages block the move. The blockers are usually old community extensions pinned to &lt;code&gt;~8.1.0&lt;/code&gt; — each one needs an upgrade or a fork before you can proceed. Get this list &lt;em&gt;before&lt;/em&gt; touching the server; it's your project plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Deprecation sweep with static analysis
&lt;/h3&gt;

&lt;p&gt;PHP 8.4 added new deprecations that bite Magento custom code specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Implicit nullable parameters&lt;/strong&gt; (&lt;code&gt;Type $x = null&lt;/code&gt; without &lt;code&gt;?Type&lt;/code&gt;) — a huge one for older modules; it's deprecated in 8.4 and will be a fatal in PHP 9. Core has been cleaned up since 2.4.8, but &lt;em&gt;your&lt;/em&gt; custom code and old third-party modules are the risk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;E_STRICT&lt;/code&gt; and other soft removals&lt;/strong&gt; — run a PHPStan/Pint pass at level 8+ and grep for &lt;code&gt;@deprecated&lt;/code&gt; usage in your vendor diff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Curl, OpenSSL and sodium changes&lt;/strong&gt; — any module doing raw HTTP or crypto calls needs re-validation on 8.4.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Property hooks and asymmetric visibility&lt;/strong&gt; are new 8.4 syntax; if a third-party module uses them and your deployment runs on an older PHP for CLI cron, it'll fatal. Keep &lt;em&gt;all&lt;/em&gt; PHP runtimes in the stack on the same major.minor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run the full test suite + a smoke list (frontend uncached page, admin login, checkout, search, cron, queue consumers, at least one import) on staging &lt;em&gt;with &lt;code&gt;error_reporting(E_ALL)&lt;/code&gt;&lt;/em&gt; so deprecations surface loudly instead of silently rotting in logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Don't forget the extensions
&lt;/h3&gt;

&lt;p&gt;PHP 8.4 needs current builds of &lt;code&gt;bcmath&lt;/code&gt;, &lt;code&gt;intl&lt;/code&gt;, &lt;code&gt;gd&lt;/code&gt;, &lt;code&gt;imagick&lt;/code&gt;, &lt;code&gt;xsl&lt;/code&gt;, &lt;code&gt;zip&lt;/code&gt;, &lt;code&gt;sodium&lt;/code&gt;, &lt;code&gt;opcache&lt;/code&gt;, &lt;code&gt;redis&lt;/code&gt;, and (if you use it) &lt;code&gt;ioncube&lt;/code&gt; — ionCube in particular lags new PHP releases, and 8.4 support required specific loader versions. Check &lt;code&gt;php -m&lt;/code&gt; against your current environment and diff the lists; a missing extension fails silently in Magento (empty pages, broken image resize) rather than loudly.&lt;/p&gt;

&lt;p&gt;On Debian/Ubuntu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;php8.4-fpm php8.4-cli php8.4-mysql php8.4-bcmath &lt;span class="se"&gt;\&lt;/span&gt;
  php8.4-intl php8.4-gd php8.4-xsl php8.4-zip php8.4-sodium &lt;span class="se"&gt;\&lt;/span&gt;
  php8.4-opcache php8.4-redis php8.4-curl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Measure before, measure after
&lt;/h3&gt;

&lt;p&gt;The whole point is measurable gains, so capture a baseline first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic&lt;/strong&gt;: benchmark an uncached PDP with &lt;code&gt;ab&lt;/code&gt;/&lt;code&gt;wrk&lt;/code&gt; (e.g. &lt;code&gt;wrk -t4 -c64 -d60s&lt;/code&gt; against a URL with FPC and Varnish bypassed) — record requests/sec and p95 latency before and after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-world&lt;/strong&gt;: run the same Blackfire or New Relic transaction profiles on the old and new runtime — compare per-transaction wall time and memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacity&lt;/strong&gt;: re-run the same concurrency test that used to push FPM to &lt;code&gt;pm.max_children&lt;/code&gt;; the new ceiling is your flash-sale headroom.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Typical honest result after 8.1 → 8.4 with opcache tuned: p95 on uncached pages down 15–25%, memory per FPM worker down 10–20%, and the same hardware handling 20–30% more concurrent uncached requests. If you see &lt;em&gt;none&lt;/em&gt; of that, check that FPM actually reloaded the new version (&lt;code&gt;php-fpm8.4 -v&lt;/code&gt; + &lt;code&gt;INFO: reloading&lt;/code&gt; in the log) — "we upgraded PHP" is a surprisingly common false claim, with old FPM sockets still serving traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Rollback plan
&lt;/h3&gt;

&lt;p&gt;Keep the old runtime installable (don't purge php8.2 packages), snapshot the FPM pool config, and make the switch a config-level toggle rather than an image rebuild: one server first, watch error logs for 24h, then the rest. A PHP upgrade gone wrong is a configuration revert, not a data migration — it should never take longer than five minutes to undo.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;PHP 8.4 isn't a magic switch, but it's the closest thing Magento performance work has to a free lunch: &lt;strong&gt;2.4.8 + PHP 8.4 + tuned opcache&lt;/strong&gt; is a measurably faster, more memory-efficient stack with zero architectural risk, and it retires a pile of old deprecations before PHP 9 makes them fatal. The upgrade is boring, mostly compatibility work — and that's exactly why you should do it before the next flash-sale season rather than during it.&lt;/p&gt;

&lt;p&gt;If you want to know whether your specific store (and its extension list) is actually 8.4-ready, run the composer platform check and the deprecation sweep first — those two commands answer 90% of the question in an afternoon.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>php</category>
    </item>
    <item>
      <title>Magento 2 Price Index: How Prices Are Stored, Cached &amp; Why Reindexing Is Slow</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Wed, 19 Aug 2026 09:02:59 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-price-index-how-prices-are-stored-cached-why-reindexing-is-slow-2e14</link>
      <guid>https://dev.to/magevanta/magento-2-price-index-how-prices-are-stored-cached-why-reindexing-is-slow-2e14</guid>
      <description>&lt;p&gt;Next time a client asks "why is my reindex slow?", the answer is almost always the &lt;strong&gt;price index&lt;/strong&gt;. It's the indexer that scales worst with catalog size, the one that chokes on webshops, B2B stores, configurable-heavy catalogs and (counter-intuitively) gets &lt;em&gt;more&lt;/em&gt; painful the more you try to "fix" it with raw SQL. Yet most developers treat it like a black box.&lt;/p&gt;

&lt;p&gt;In this guide I'll pull back the curtain on how Magento actually stores and computes prices, why the price index behaves the way it does, and — crucially — the strategies that actually move the needle.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Magento stores prices (the part nobody reads)
&lt;/h2&gt;

&lt;p&gt;Prices do &lt;strong&gt;not&lt;/strong&gt; live on the product table. When you save a product, Magento stores the raw price on &lt;code&gt;catalog_product_entity_decimal&lt;/code&gt; — one row per product, attribute, store and scope.&lt;/p&gt;

&lt;p&gt;But the moment you add any of the following, the "true" price stops being a simple column lookup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tier prices&lt;/strong&gt; (&lt;code&gt;catalog_product_entity_tier_price&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Special price&lt;/strong&gt; scheduling (&lt;code&gt;special_from_date&lt;/code&gt; / &lt;code&gt;special_to_date&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catalog price rules&lt;/strong&gt; (&lt;code&gt;catalogrule&lt;/code&gt;, applied via rules engine)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group prices&lt;/strong&gt; (customer groups)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle/grouped product&lt;/strong&gt; composite pricing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configurable products&lt;/strong&gt; with per-option price adjustments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging updates&lt;/strong&gt; (Content Staging, Magento Commerce)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the &lt;em&gt;effective&lt;/em&gt; price depends on time, customer group and rules, Magento has to &lt;strong&gt;precompute&lt;/strong&gt; it. That precomputation is the price index. When it works, page requests just read a flat, pre-joined set of rows instead of re-evaluating every rule on every request. When it's stale or under-built, you either serve wrong prices or you trigger expensive on-the-fly calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the price index actually is
&lt;/h2&gt;

&lt;p&gt;The price indexer writes to the &lt;code&gt;catalog_product_index_price&lt;/code&gt; table (plus &lt;code&gt;_idx&lt;/code&gt;/&lt;code&gt;_tmp&lt;/code&gt; variants and the &lt;code&gt;catalog_product_index_price_final_tmp&lt;/code&gt; intermediate tables). A reindex runs in phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reindex all products&lt;/strong&gt; into the &lt;code&gt;_tmp&lt;/code&gt; table with their base price.&lt;/li&gt;
&lt;li&gt;Apply &lt;strong&gt;tax&lt;/strong&gt;, &lt;strong&gt;tier&lt;/strong&gt;, &lt;strong&gt;special&lt;/strong&gt;, &lt;strong&gt;group&lt;/strong&gt; and &lt;strong&gt;rule&lt;/strong&gt; adjustments.&lt;/li&gt;
&lt;li&gt;Handle &lt;strong&gt;composite&lt;/strong&gt; products (bundle's min/max, grouped's sums).&lt;/li&gt;
&lt;li&gt;Merge into the final &lt;code&gt;catalog_product_index_price&lt;/code&gt; (and customer-group / website / store dimension rows).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is why the table balloons: one row per product × website × customer group. A 50k SKU store across 2 websites and 4 customer groups produces &lt;strong&gt;400,000&lt;/strong&gt; price-index rows — and that's before configurable option permutations and staged versions multiply it further.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it's slow (the real reasons)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. It re-evaluates every product, not just changed ones
&lt;/h3&gt;

&lt;p&gt;The full reindex (&lt;code&gt;bin/magento indexer:reindex catalog_product_price&lt;/code&gt;) rebuilds the entire dimension space. Unlike the URL-rewrite indexer, the price indexer historically had weak partial-reindex support, so a single product save could kick off a large portion of the rebuild. &lt;code&gt;Mview&lt;/code&gt; (the changelog-based indexer) helps, but on heavy write stores it can still fall behind — and the moment it falls too far behind, Magento falls back to a synchronous rebuild mid-request.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Configurable products are a multiplier
&lt;/h3&gt;

&lt;p&gt;Every configurable product with 50 options effectively fans out into 50 price rows (minus on-demand fallbacks). Storefronts with huge configurable catalogs see the price indexer take orders of magnitude longer than other indexers for the same SKU count.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Catalog price rules force full re-evaluation
&lt;/h3&gt;

&lt;p&gt;Unlike tier prices (stored per row), &lt;strong&gt;catalog price rules are computed by a rule engine&lt;/strong&gt; that must evaluate each product against each rule's conditions. That's not a column read — it's a rules evaluation over the whole catalog. The MySQL-based rules engine (&lt;code&gt;catalogrule&lt;/code&gt;) is notoriously slow, and the indexer runs it &lt;em&gt;on every reindex&lt;/em&gt;. This is the single most common reason a price reindex takes minutes to hours on real stores.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. It's CPU-, I/O- and memory-bound all at once
&lt;/h3&gt;

&lt;p&gt;The price indexer builds large temporary tables, sorts them, and merges them. On shared hosting or under-provisioned MySQL (tiny &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt;, no SSD), this is brutal. And because Magento can run indexers in a limited number of ways by default, you can't easily parallelize it without care.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure before you touch anything
&lt;/h2&gt;

&lt;p&gt;Never optimize blind. Establish a baseline first:&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="c"&gt;# Look at the "Schedule Status" for catalog_product_price — is it in Update by Schedule? Behind?&lt;/span&gt;

&lt;span class="c"&gt;# Time a full build&lt;/span&gt;
&lt;span class="nb"&gt;time &lt;/span&gt;bin/magento indexer:reindex catalog_product_price

&lt;span class="c"&gt;# See row counts (the big picture and its dimensions)&lt;/span&gt;
mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SELECT COUNT(*) FROM catalog_product_index_price;"&lt;/span&gt;
mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SELECT COUNT(*) FROM catalog_product_index_price_final_tmp;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check &lt;code&gt;catalog_product_index_price&lt;/code&gt; row count against your SKU count. If it's 5–10× your product count, you have a multiplication problem (websites × groups × option permutations) that no "tune the server" advice will fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strategies that actually help
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Strategy 1 — Question whether the price indexer should exist at all
&lt;/h3&gt;

&lt;p&gt;This sounds heretical but it's the highest-leverage move: &lt;strong&gt;the more you can move price logic out of the indexer, the faster it gets.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Replace catalog price rules with tier prices or per-product special price where feasible.&lt;/strong&gt; A rule that applies "10% off category X" is convenient, but if it covers a large category it forces full rule re-evaluation on every reindex. Tier prices are stored per row and are dramatically cheaper to index. Before refactoring, check whether your rules are static enough to be converted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid time-scheduled rules with tight windows.&lt;/strong&gt; Every &lt;code&gt;special_from/to&lt;/code&gt; and scheduled rule means the previous/next value differs, forcing the indexer to track time dimensions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you &lt;em&gt;must&lt;/em&gt; keep rules, at least reduce rule condition complexity. Each condition touches an attribute — complex conditions (category membership blends, multiple AND/OR groups) multiply evaluation cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Strategy 2 — Feed the database properly
&lt;/h3&gt;

&lt;p&gt;The price indexer is a database workload, so it responds to database provisioning:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put MySQL on &lt;strong&gt;NVMe SSD&lt;/strong&gt;. This is often the single biggest win for indexers — the difference between a 20-minute and a 3-minute reindex on big catalogs.&lt;/li&gt;
&lt;li&gt;Raise &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt; to 70–80% of available RAM (on a dedicated DB server) so the &lt;code&gt;_tmp&lt;/code&gt; tables and sort buffers live in memory.&lt;/li&gt;
&lt;li&gt;Increase &lt;code&gt;tmp_table_size&lt;/code&gt; and &lt;code&gt;max_heap_size&lt;/code&gt; so intermediate price tables stay as in-memory &lt;code&gt;MEMORY&lt;/code&gt; tables instead of spilling to disk.&lt;/li&gt;
&lt;li&gt;Watch &lt;code&gt;innodb_io_capacity&lt;/code&gt; and &lt;code&gt;innodb_io_capacity_max&lt;/code&gt; — matching them to your actual device prevents InnoDB from under-using the disk during large writes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Strategy 3 — Run it at the right time, the right way
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Schedule the price indexer to run &lt;strong&gt;off-peak&lt;/strong&gt; via cron, and keep &lt;code&gt;indexer:status&lt;/code&gt; in "Update by Schedule" for it.&lt;/li&gt;
&lt;li&gt;Prevent concurrent indexers from fighting each other. Multiple heavy indexers (price + search + URL rewrites) running at once on one DB share the same InnoDB buffer pool and I/O. Stagger them in your crontab.&lt;/li&gt;
&lt;li&gt;For very large catalogs, run the indexer &lt;strong&gt;on a staging/standby DB replica&lt;/strong&gt; and promote it, rather than hammering your live DB mid-day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Strategy 4 — Reduce the multiplication
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Audit customer groups. If you have dozens of groups that all resolve to identical pricing, you're paying for N× the rows for no benefit. Consolidate groups whose pricing is actually the same — the price indexer is one of the biggest hidden costs of group sprawl.&lt;/li&gt;
&lt;li&gt;Audit websites/store views. Same logic: each website adds a dimension. If an extra website doesn't have distinct pricing, you may be indexing duplicate data.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Strategy 5 — Don't hand-edit the price tables (stop the nightmare)
&lt;/h3&gt;

&lt;p&gt;A recurring anti-pattern from novice "performance fixes": someone truncates or hand-updates &lt;code&gt;catalog_product_index_price&lt;/code&gt; "to make the site faster." This &lt;strong&gt;immediately&lt;/strong&gt; produces wrong storefront prices (the storefront reads the index, not the base table) and corrupts the changelog, causing the indexer to fall back to full rebuilds. Never touch &lt;code&gt;*_index_price&lt;/code&gt; tables directly. If you need a cached price override, use the proper entity/rule mechanisms and reindex cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on partial reindexing and Mview
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;catalog_product_price&lt;/code&gt; uses &lt;code&gt;Mview&lt;/code&gt; (change-log based updating) so that incremental saves update only the affected products. This works well when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your store is &lt;strong&gt;write-light&lt;/strong&gt; (few saves per second) — the changelog stays tiny and watchers keep up.&lt;/li&gt;
&lt;li&gt;You &lt;strong&gt;don't&lt;/strong&gt; run heavy catalog price rules (rules still trigger broad re-evaluation).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On &lt;strong&gt;write-heavy&lt;/strong&gt; dynamic-pricing stores (frequent price imports, B2B negotiated pricing via staged updates), Mview can fall behind and you end up with a runaway backlog. In those cases, controlled off-peak full reindexes are often more predictable than trusting incremental updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The practical checklist
&lt;/h2&gt;

&lt;p&gt;To summarize, here's what to audit when price reindexing is slow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Row-count ratio&lt;/strong&gt; — is the price index 5–10× your SKU count? Attack the dimensions (groups/websites/options), not the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catalog price rules&lt;/strong&gt; — are big static rules forcing full rule-engine evaluation every reindex? Migrate to tier/special prices where possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage&lt;/strong&gt; — NVMe + &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt; + in-memory temp tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling&lt;/strong&gt; — off-peak, staggered, non-overlapping with other heavy indexers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; hand-edit &lt;code&gt;*_index_price&lt;/code&gt; tables.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Final word
&lt;/h2&gt;

&lt;p&gt;The price index is where "it's just a cache" thinking fails. It's a real computational pipeline that re-derives the effective price for every product across every dimension. If you respect what it's doing — precomputing complex pricing so the storefront can serve pages fast — you can make informed choices: shrink the dimensions, simplify the rules, feed the DB, and schedule intelligently. Do that, and price reindexing goes from a feared maintenance window to a quiet background job.&lt;/p&gt;

&lt;p&gt;Want me to cover a specific aspect of Magento pricing next — like catalog price rule internals or composite-product price calculation? Drop it in the comments.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Sales Order Performance: Grid Indexing, Order Loading &amp; Bulk Operations</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Tue, 18 Aug 2026 09:01:57 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-sales-order-performance-grid-indexing-order-loading-bulk-operations-ka1</link>
      <guid>https://dev.to/magevanta/magento-2-sales-order-performance-grid-indexing-order-loading-bulk-operations-ka1</guid>
      <description>&lt;p&gt;Most Magento 2 performance articles focus on the storefront — caching, Varnish, product pages. But there's a second performance front that hurts just as much: the &lt;strong&gt;backend order management&lt;/strong&gt;. When your shop grows past a few thousand orders, the sales order grid slows down, the order view page takes seconds to load, exports time out, and your customer service team starts waiting on screens instead of helping customers.&lt;/p&gt;

&lt;p&gt;The frustrating part? This degradation is gradual. It sneaks up on you. One day the grid loads in 300ms, a year later it takes 8 seconds — and nobody knows why.&lt;/p&gt;

&lt;p&gt;This guide breaks down the three biggest order-management bottlenecks in Magento 2, and what to do about each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Magento 2 Stores and Loads Orders
&lt;/h2&gt;

&lt;p&gt;Before optimizing, it helps to understand the sales data model. Magento 2 spreads every order across many tables:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sales_order&lt;/code&gt; — the order base record (customer, totals, status)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sales_order_item&lt;/code&gt; — ordered products&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sales_order_address&lt;/code&gt; — billing and shipping addresses&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sales_order_payment&lt;/code&gt; — payment details&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sales_order_status_history&lt;/code&gt; — status comments and history&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sales_order_grid&lt;/code&gt; — a &lt;strong&gt;denormalized&lt;/strong&gt; table built for fast grid rendering&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sales_invoice&lt;/code&gt;, &lt;code&gt;sales_shipment&lt;/code&gt;, &lt;code&gt;sales_creditmemo&lt;/code&gt; — their grids and items&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key table is &lt;code&gt;sales_order_grid&lt;/code&gt;. Since Magento 2.1 it's maintained by a dedicated indexer (&lt;code&gt;sales_grid_order_indexer&lt;/code&gt;) that copies the most relevant order fields into a flat, query-friendly structure. The grid UI components, the order export, and many admin listings all query this table — not &lt;code&gt;sales_order&lt;/code&gt; directly.&lt;/p&gt;

&lt;p&gt;That design is smart in theory. In practice, it's where most order-management performance problems start.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Sales Grid Indexer Is Your First Bottleneck
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;sales_grid_order_indexer&lt;/code&gt; (and its siblings for invoices, shipments and credit memos) runs every time an order changes: new order, status update, comment added, invoice created. On a busy shop that's thousands of reindex operations per day.&lt;/p&gt;

&lt;p&gt;Three things typically go wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The indexer runs synchronously.&lt;/strong&gt; By default the indexer is set to "Update on Save", meaning every order save triggers a full grid update inline — blocking the checkout queue and admin operations. &lt;strong&gt;Fix:&lt;/strong&gt; switch all sales grid indexers to &lt;em&gt;Update by Schedule&lt;/em&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:set-mode schedule sales_grid_order_indexer
bin/magento indexer:set-mode schedule sales_grid_invoice_indexer
bin/magento indexer:set-mode schedule sales_grid_shipment_indexer
bin/magento indexer:set-mode schedule sales_grid_creditmemo_indexer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The cron schedule is too aggressive or too sparse.&lt;/strong&gt; If you set the schedule to run every minute, each run still processes the delta since the last run. If it runs hourly on a high-volume shop, the grid is constantly stale and queries hit partially updated rows. Measure the actual indexer duration:&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:show-mode
bin/magento indexer:status
&lt;span class="c"&gt;# Or via SQL: check the last run duration&lt;/span&gt;
SELECT &lt;span class="k"&gt;*&lt;/span&gt; FROM mview_state WHERE view_id IN &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sales_order_grid'&lt;/span&gt;, &lt;span class="s1"&gt;'sales_grid_invoice_grid'&lt;/span&gt;, ...&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy setup indexes every 5–15 minutes and finishes in seconds. If a run takes minutes, see section 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every comment creates a grid update.&lt;/strong&gt; Status history comments trigger grid reindexes too. If your ERP or integration posts dozens of comment updates per order, that's dozens of grid refreshes. Check your &lt;code&gt;sales_order_status_history&lt;/code&gt; growth rate — if comments spam is high, consider whether every comment truly needs to mutate the grid, or whether your integration can batch its updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Grid Bloat, Orphans and Table Maintenance
&lt;/h2&gt;

&lt;p&gt;Over months, the &lt;code&gt;sales_order_grid&lt;/code&gt; table accumulates &lt;strong&gt;orphaned rows&lt;/strong&gt;: orders are deleted from &lt;code&gt;sales_order&lt;/code&gt; (mass delete, GDPR erasure, test cleanup) but rows linger in the grid. Every orphan is dead weight in every grid query, and the grid indexer keeps trying to sync them.&lt;/p&gt;

&lt;p&gt;A quick diagnostic:&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;-- Orphaned grid rows&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;sales_order_grid&lt;/span&gt; &lt;span class="k"&gt;g&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;sales_order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns thousands of rows, clean them up:&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;# With magerun2 — the safe way&lt;/span&gt;
n98-magerun2 index:reindex sales_grid_order_indexer:full &lt;span class="nt"&gt;--force&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or manually, after a backup:&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;DELETE&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sales_order_grid&lt;/span&gt; &lt;span class="k"&gt;g&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;sales_order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For shops with millions of orders, think about &lt;strong&gt;archiving instead of deleting&lt;/strong&gt;. Magento ships with a built-in Sales Archive feature (Stores → Configuration → Sales → Sales Archive) that moves old orders to &lt;code&gt;sales_order_archive&lt;/code&gt; tables and removes them from the live grid. My recommended threshold: archive orders older than 12–18 months — customer service rarely needs them in the live grid, and order data remains fully accessible via the archive UI.&lt;/p&gt;

&lt;p&gt;If you're on MySQL 8 / MariaDB and run into index rebuild pain on multi-million-row grids, consider &lt;strong&gt;table partitioning on &lt;code&gt;created_at&lt;/code&gt;&lt;/strong&gt; (monthly or quarterly partitions) so the indexer and grid queries only touch recent partitions. This is a bigger change — test it thoroughly on a clone first — but it's the single most effective fix for truly huge order tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. N+1 Order Loading — the Hidden Admin Killer
&lt;/h2&gt;

&lt;p&gt;The grid being slow is one thing. The &lt;strong&gt;order view page&lt;/strong&gt; being slow is another — and it's almost always N+1 queries.&lt;/p&gt;

&lt;p&gt;The classic symptom: opening a single order takes 3–6 seconds even though the grid was instant. What's happening under the hood:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The order is loaded via the repository or &lt;code&gt;load()&lt;/code&gt; with its related entities&lt;/li&gt;
&lt;li&gt;Then each block does its own lazy loading: items, addresses, payment info, status history, invoices, shipments, credit memos, custom extension attributes from third-party modules&lt;/li&gt;
&lt;li&gt;Every lazy load fires new queries — and some extension attributes trigger &lt;em&gt;nested&lt;/em&gt; loads per item&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Third-party modules are the usual suspects: ERP sync modules, order comments modules, invoice PDF modules that attach attributes to every order load. A quick way to find them is to enable SQL logging or profiler output on the admin order page and count the queries — if one order view fires 200+ queries, you have a problem.&lt;/p&gt;

&lt;p&gt;Standard countermeasures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Batch-load instead of loop-load&lt;/strong&gt; wherever your code touches orders:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;  &lt;span class="c1"&gt;// Bad: N+1 — a query per order&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;$orderIds&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nv"&gt;$order&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;orderRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orderId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nv"&gt;$total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getGrandTotal&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Good: one query for all orders, one for all items&lt;/span&gt;
  &lt;span class="nv"&gt;$orderCollection&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;orderCollectionFactory&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;$orderCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addFieldToFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'entity_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'in'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orderIds&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nv"&gt;$orderCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSelect&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;joinLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'soi'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orderCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sales_order_item'&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
      &lt;span class="s1"&gt;'soi.order_id = main_table.entity_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'items_grand_total'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SUM(soi.row_total_incl_tax)'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nv"&gt;$orderCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSelect&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;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'main_table.entity_id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Disable unused extension attributes&lt;/strong&gt; on the order entity (&lt;code&gt;extension_attributes.xml&lt;/code&gt;) so they're not loaded on every order load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prevent lazy-loading loops&lt;/strong&gt; in your own modules: if you need invoices for an order, fetch them once with a collection and map them by &lt;code&gt;order_id&lt;/code&gt;, never per-order.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. Bulk Operations: Exports, Mass Actions and ERP Sync
&lt;/h2&gt;

&lt;p&gt;Order export, mass status changes, and ERP polling are the third performance front. These jobs look like they run "in the background", but if they're implemented as synchronous PHP loops, they're hammering your database, consuming PHP-FPM workers, and slowing down everything else — including the storefront.&lt;/p&gt;

&lt;p&gt;The correct pattern is always the same:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move heavy work to a message queue.&lt;/strong&gt; Magento 2 has built-in support via RabbitMQ or the database message queue. Wrap bulk order operations in async consumers:&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;!-- etc/queue_consumer.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-message-queue:etc/consumer.xsd"&lt;/span&gt;&lt;span class="nt"&gt;&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;"order.export.batch"&lt;/span&gt;
              &lt;span class="na"&gt;queue=&lt;/span&gt;&lt;span class="s"&gt;"order.export.queue"&lt;/span&gt;
              &lt;span class="na"&gt;handler=&lt;/span&gt;&lt;span class="s"&gt;"Vendor\Module\Model\OrderExportConsumer::process"&lt;/span&gt;
              &lt;span class="na"&gt;connection=&lt;/span&gt;&lt;span class="s"&gt;"amqp"&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;Then process batches with configurable size, and throttle:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&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="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$batchSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// tune based on your DB&lt;/span&gt;
    &lt;span class="nv"&gt;$orderIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&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;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBody&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_chunk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$orderIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$batchSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$orders&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="nf"&gt;loadOrders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// one collection query&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;$orders&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$order&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;exportOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// give the DB a breath between chunks&lt;/span&gt;
        &lt;span class="nb"&gt;usleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100_000&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;Two more wins for bulk operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chunking + temp tables for exports.&lt;/strong&gt; For very large exports, write the selected IDs to a temp table first, then stream pages of 1000 rows — instead of one giant &lt;code&gt;IN (...)&lt;/code&gt; with 100k IDs that blows up the query planner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indexes that match your ERP queries.&lt;/strong&gt; Your ERP integration probably polls orders by status and date. Make sure these queries are indexed:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;sales_order&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="nv"&gt;`IDX_STATUS_CREATED_AT`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;`status`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`created_at`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;sales_order&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="nv"&gt;`IDX_CUSTOMER_ID_CREATED_AT`&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;`customer_id`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;`created_at`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A missing index on &lt;code&gt;status + created_at&lt;/code&gt; is one of the most common reasons ERP polls grind the database — the query scans the whole order table on every sync cycle.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Optimizing the Admin Grid Itself
&lt;/h2&gt;

&lt;p&gt;The order grid UI component can be optimized too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Only enable columns you need.&lt;/strong&gt; Every visible column adds a query fragment. Disable rarely-used columns (configurable via &lt;code&gt;di.xml&lt;/code&gt; or the &lt;code&gt;sales_order_grid&lt;/code&gt; UI component XML) to keep the grid SELECT slim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter on indexed columns only.&lt;/strong&gt; Custom grid columns that filter on non-indexed fields force full scans of &lt;code&gt;sales_order_grid&lt;/code&gt;. If you must filter on a field, add the corresponding index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the grid page size.&lt;/strong&gt; Set &lt;code&gt;pageSize&lt;/code&gt; to a sane default (20–25) instead of letting users request 100-row pages on a 2M-row grid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the built-in sorting on indexed columns&lt;/strong&gt; (&lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;increment_id&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;) — sorting on arbitrary columns costs an extra filesort.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Monitoring so It Never Sneaks Up Again
&lt;/h2&gt;

&lt;p&gt;Finally, set up basic monitoring around order management so the next regression is caught early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Track grid indexer duration&lt;/strong&gt; (&lt;code&gt;mview_state&lt;/code&gt; timestamps) — alert if a run exceeds your baseline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch &lt;code&gt;sales_order_grid&lt;/code&gt; row count&lt;/strong&gt; vs &lt;code&gt;sales_order&lt;/code&gt; row count for orphan growth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable the MySQL slow query log&lt;/strong&gt; on a staging or low-traffic replica and scan for &lt;code&gt;sales_order&lt;/code&gt; / &lt;code&gt;sales_order_grid&lt;/code&gt; queries with high execution time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Count queries on the order view page&lt;/strong&gt; weekly — a regression from 80 to 300 queries per page load is an early warning sign of a badly written extension attribute or module.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;To recap, this is your order-management performance playbook:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set all four sales grid indexers to &lt;em&gt;Update by Schedule&lt;/em&gt; (5–15 min interval)&lt;/li&gt;
&lt;li&gt;Clean orphaned grid rows quarterly; archive orders older than 12–18 months&lt;/li&gt;
&lt;li&gt;Partition huge order tables on &lt;code&gt;created_at&lt;/code&gt; if you're past millions of rows&lt;/li&gt;
&lt;li&gt;Kill N+1 order loading — batch collections, prune extension attributes&lt;/li&gt;
&lt;li&gt;Move exports, mass actions and ERP sync to message queues with chunked processing&lt;/li&gt;
&lt;li&gt;Index &lt;code&gt;status + created_at&lt;/code&gt; and &lt;code&gt;customer_id + created_at&lt;/code&gt; for integration queries&lt;/li&gt;
&lt;li&gt;Keep the admin grid lean: fewer columns, indexed filters, capped page size&lt;/li&gt;
&lt;li&gt;Monitor indexer duration, grid growth and order-view query counts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Order management performance doesn't get the glory of Varnish or Redis tuning, but your customer service team, your ERP integration and your sanity will thank you. Fix the grid indexer, kill the N+1s, and queue the bulk work — that's 80% of the problem, solved.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Media Storage Performance: Database, Filesystem or S3?</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Mon, 17 Aug 2026 09:02:28 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-media-storage-performance-database-filesystem-or-s3-1md6</link>
      <guid>https://dev.to/magevanta/magento-2-media-storage-performance-database-filesystem-or-s3-1md6</guid>
      <description>&lt;p&gt;Every Magento store starts with a simple &lt;code&gt;media/&lt;/code&gt; folder and nobody thinks about it. Then the catalog grows, image resizes multiply, the deploy takes twenty minutes, and the server starts throwing "No space left on device" — while &lt;code&gt;df -h&lt;/code&gt; shows 80% free. That's the moment you discover you've run out of &lt;em&gt;inodes&lt;/em&gt;, not bytes. Media storage is one of the most underestimated performance bottlenecks in Magento 2, because it doesn't show up in MySQL slow logs or Blackfire traces. It shows up as slow deploys, stalled image requests, NFS timeouts and sync processes that never finish.&lt;/p&gt;

&lt;p&gt;This post breaks down how Magento actually stores and generates media, where the performance traps are hiding, and how to choose between filesystem, database and object storage — without breaking your store.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Magento 2 Stores Media (Under the Hood)
&lt;/h2&gt;

&lt;p&gt;Magento's media catalog lives in &lt;code&gt;&amp;lt;store_root&amp;gt;/pub/media&lt;/code&gt;, and the interesting parts are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;catalog/product/&lt;/code&gt; — original product images, organized by first letter of the filename (&lt;code&gt;a/b/&lt;/code&gt;, &lt;code&gt;c/d/&lt;/code&gt;, ...). This scatter is intentional: it keeps directory sizes manageable on filesystems that struggle with huge flat directories.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;catalog/product/cache/&lt;/code&gt; — &lt;strong&gt;generated, resized images&lt;/strong&gt;. Every resized variant (category pages, product pages, cart thumbnails, widgets) gets written here, keyed by the resize parameters.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;catalog/category/&lt;/code&gt;, &lt;code&gt;theme/&lt;/code&gt;, &lt;code&gt;wysiwyg/&lt;/code&gt;, &lt;code&gt;tmp/&lt;/code&gt;, &lt;code&gt;import/&lt;/code&gt; — the rest of the ecosystem.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;media_storage_file&lt;/code&gt; and &lt;code&gt;media_gallery&lt;/code&gt; rows in the database — the metadata layer (only fully used in DB storage mode, see below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The critical thing to understand: &lt;strong&gt;a typical Magento store ends up with millions of tiny files&lt;/strong&gt;. Each product image spawns anywhere from 5 to 30 resized variants depending on your theme. With 50,000 products and 3 images each, you're looking at &lt;strong&gt;750K–4.5M files&lt;/strong&gt;. That's not a storage problem — it's a &lt;em&gt;filesystem&lt;/em&gt; problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Trap #1: Inodes and Directory Scalability
&lt;/h2&gt;

&lt;p&gt;On a default ext4 filesystem, every file and directory consumes one inode. &lt;code&gt;df -h&lt;/code&gt; reports &lt;em&gt;bytes&lt;/em&gt;, &lt;code&gt;df -i&lt;/code&gt; reports &lt;em&gt;inodes&lt;/em&gt;. A busy store with 4 million media files can exhaust the inode pool on a 100GB disk that's only half full. When that happens, &lt;strong&gt;any write fails&lt;/strong&gt; — logs, sessions, deploys, image generation — and the failure mode is confusing because the disk looks fine.&lt;/p&gt;

&lt;p&gt;Check it before you ever get close to the edge:&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="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; /var/www/magento
&lt;span class="c"&gt;# Filesystem      Inodes  IUsed   IFree IUse% Mounted on&lt;/span&gt;
&lt;span class="c"&gt;# /dev/sda1      6553600 6500210  53390   99% /&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fixes, in order of preference:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use XFS instead of ext4 for new volumes&lt;/strong&gt; (XFS allocates inodes dynamically — no fixed pool to exhaust).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move &lt;code&gt;pub/media&lt;/code&gt; to a dedicated volume&lt;/strong&gt; (own inode pool, own I/O, own backup policy).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never store media on NFS with default settings&lt;/strong&gt; — NFS is the classic cause of "image requests hanging for 30 seconds". If you must, use NFSv4 with &lt;code&gt;actimeo=600&lt;/code&gt; and a dedicated export, or better: move to object storage (below).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Also check your filesystem's directory index. ext4 uses hashed directory entries, which is fine, but on shared/NAS filesystems even reading a directory with 50,000 entries can take seconds. Run &lt;code&gt;ls -U&lt;/code&gt; (unsorted, no stat) to see raw read speed — if that's slow, the filesystem is the bottleneck, not Magento.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Trap #2: On-the-Fly Image Resizing Races
&lt;/h2&gt;

&lt;p&gt;Here's the dirty secret of Magento's image pipeline: when a resized variant doesn't exist in &lt;code&gt;catalog/product/cache/&lt;/code&gt;, the frontend &lt;strong&gt;generates it on demand&lt;/strong&gt; during the request that needs it. The first visitor to hit a category page pays the resizing cost — and in a multi-server setup, &lt;em&gt;every&lt;/em&gt; server races to generate the same images, because there's no shared lock.&lt;/p&gt;

&lt;p&gt;That's why you sometimes see &lt;code&gt;flock()&lt;/code&gt; warnings in &lt;code&gt;var/log/&lt;/code&gt; about &lt;code&gt;image cache&lt;/code&gt; locks, and why a cache flush can spike your CPU to 100% for an hour while the "warm" cache rebuilds itself on live traffic.&lt;/p&gt;

&lt;p&gt;Two things to do about it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pre-generate during deploy&lt;/strong&gt;, not on first request:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento catalog:images:resize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this on your build/deploy server for all themes and locales, and ship the generated &lt;code&gt;cache/&lt;/code&gt; directory with the release (or sync it to a shared location). Combined with a CDN in front, subsequent deploys become much cheaper because the CDN already holds most variants.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Consider dynamic image resizing only where it pays off&lt;/strong&gt; — some CDNs (Cloudflare, Fastly, imgix-style services) can resize on the edge. If you go that route, you can point &lt;code&gt;catalog:images:base_url&lt;/code&gt; at the resize service and disable local generation for most sizes. It trades local CPU for CDN cost — usually the right trade at scale.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Performance Trap #3: The Database Storage Mode
&lt;/h2&gt;

&lt;p&gt;Magento ships with a &lt;strong&gt;database media storage&lt;/strong&gt; option (&lt;code&gt;Stores → Configuration → Advanced → System → Storage Configuration for Media&lt;/code&gt;, or &lt;code&gt;bin/magento config:set system/media_storage_configuration/media_storage 1&lt;/code&gt;). All media files are stored as BLOBs in &lt;code&gt;media_storage_file&lt;/code&gt;, with metadata in &lt;code&gt;media_storage_file_storage&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When is DB storage a good idea? Almost never for production — but it's a lifesaver for &lt;strong&gt;multi-node setups that can't share a filesystem&lt;/strong&gt;. Instead of NFS, all nodes read/write media through MySQL, which is already replicated.&lt;/p&gt;

&lt;p&gt;The price: every image request is a DB round-trip (mitigated by the cache/ dir), the &lt;code&gt;media_storage_file&lt;/code&gt; table grows to hundreds of GB, and the DB backup gets brutally slow. The sync commands are also a classic source of pain:&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 media:sync
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a large catalog this can run for &lt;strong&gt;days&lt;/strong&gt;, and it's a single-process command — you can't parallelize it out of the box. If you switch storage modes, plan a maintenance window and test the sync on a copy first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; use DB storage only when you have no shared filesystem &lt;em&gt;and&lt;/em&gt; no budget for object storage. In every other case, one of the options below wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance Trap #4: Object Storage (S3) — The Scalable Answer
&lt;/h2&gt;

&lt;p&gt;The modern answer for scale is object storage: an S3-compatible bucket (AWS S3, DigitalOcean Spaces, MinIO, Cloudflare R2) behind your CDN. Magento has first-party S3 support (&lt;code&gt;magento/module-remote-storage&lt;/code&gt; + the AWS S3 module), and the config is simple:&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 setup:config:set &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remote-storage-driver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"aws-s3"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remote-storage-bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"my-store-media"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remote-storage-prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"media/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With remote storage enabled, Magento &lt;strong&gt;lazily offloads&lt;/strong&gt; files to the bucket when they're requested or written, and serves them from the bucket via the CDN. The local &lt;code&gt;media/&lt;/code&gt; directory stays small (just recent files and the cache), deploys become fast, and you get:&lt;strong&gt;precise metrics&lt;/strong&gt; — S3 buckets give you object counts and transfer costs, which beats guessing about a local folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tradeoffs to know before you switch:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First-request latency.&lt;/strong&gt; Lazy sync means the first request for an old image triggers a fetch from S3. Pre-warm the bucket with a full &lt;code&gt;media:sync&lt;/code&gt; during a maintenance window, or accept a one-time warmup cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache stampedes return.&lt;/strong&gt; The &lt;code&gt;cache/&lt;/code&gt; resize directory is local by default, so multi-node stores without a shared cache directory will re-generate variants per node again. Fix by warming on build and letting the CDN absorb traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signed URLs for private media&lt;/strong&gt; (e.g. PDF downloads behind login) need extra config — don't skip it, or you'll leak signed links.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs.&lt;/strong&gt; S3 GET/PUT pricing is cheap at normal traffic levels, but a bad cache policy can run up a bill. 24/7 public CDN-plus-bucket access patterns are fine; direct bucket access from the origin with no CDN is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Performance Trap #5: The Media Gallery Table Itself
&lt;/h2&gt;

&lt;p&gt;Even with perfect storage, the metadata layer can bite you. &lt;code&gt;catalog_product_entity_media_gallery&lt;/code&gt; and its &lt;code&gt;_value&lt;/code&gt; table grow one row per image per store view — and every &lt;code&gt;Save&lt;/code&gt; on a product with a large gallery rewrites the whole gallery asset list. With the new Media Gallery (Adobe Stock integration), batch operations run through &lt;code&gt;media_gallery_asset&lt;/code&gt; tables, which Magento fills with &lt;strong&gt;millions of rows&lt;/strong&gt; on stores with large media folders.&lt;/p&gt;

&lt;p&gt;Signs you're hitting this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;SELECT&lt;/code&gt; on &lt;code&gt;catalog_product_entity_media_gallery_value&lt;/code&gt; shows up in slow query logs on product saves and category renders.&lt;/li&gt;
&lt;li&gt;Admin Media Gallery pages take forever to load the folder tree.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;media:sync&lt;/code&gt; crawls because every file becomes an &lt;code&gt;asset&lt;/code&gt; row.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practical fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Index the obvious joins&lt;/strong&gt; if your install predates index fixes: &lt;code&gt;(entity_id, store_id)&lt;/code&gt; and &lt;code&gt;(value_id, store_id)&lt;/code&gt; composite indexes on the &lt;code&gt;_value&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't keep years of orphaned media.&lt;/strong&gt; The "Media Gallery" bulk delete tool exists for a reason — use it, then &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; the gallery tables.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch category/product saves in bulk.&lt;/strong&gt; If a bulk update touches 10,000 products with galleries, break it into chunks of ~500. Each save rewrites gallery rows; chunking keeps row locks short and replication lag low.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A Decision Framework for Your Store
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Store size&lt;/th&gt;
&lt;th&gt;Files (est.)&lt;/th&gt;
&lt;th&gt;Best storage&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Small (&amp;lt; 5K SKUs)&lt;/td&gt;
&lt;td&gt;&amp;lt; 200K&lt;/td&gt;
&lt;td&gt;Local SSD + CDN&lt;/td&gt;
&lt;td&gt;Simplest, fast enough, no ops complexity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium (5–50K SKUs)&lt;/td&gt;
&lt;td&gt;200K–1M&lt;/td&gt;
&lt;td&gt;Local SSD + CDN, or S3 for peace of mind&lt;/td&gt;
&lt;td&gt;Filesystem still fine if inodes are sized right&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large (50K+ SKUs, multi-node)&lt;/td&gt;
&lt;td&gt;1M+&lt;/td&gt;
&lt;td&gt;S3 (or object storage) + CDN&lt;/td&gt;
&lt;td&gt;Inode pressure, NFS pain, deploy speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-node, no budget&lt;/td&gt;
&lt;td&gt;Any&lt;/td&gt;
&lt;td&gt;DB storage as last resort&lt;/td&gt;
&lt;td&gt;Avoids NFS; accepts DB growth and slow sync&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Whatever you pick, there's a non-negotiable baseline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check inodes:&lt;/strong&gt; &lt;code&gt;df -i&lt;/code&gt; in your deploy runbook. Exhausting inodes is a full outage with a 5-minute fix that nobody finds for 2 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-generate image cache on build&lt;/strong&gt; (&lt;code&gt;catalog:images:resize&lt;/code&gt;) so first-request generation races never hit production traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put a CDN in front of &lt;code&gt;media/&lt;/code&gt;&lt;/strong&gt; (this blog covered CDN config in depth before) — it converts origin image load from "per-visitor" to "per-variant-once".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor sync jobs.&lt;/strong&gt; &lt;code&gt;media:sync&lt;/code&gt; and &lt;code&gt;catalog:images:resize&lt;/code&gt; in &lt;code&gt;var/log&lt;/code&gt; with start/end timestamps; alert when they exceed your deploy window. A sync that never finishes is the first symptom of a storage architecture change.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Media storage doesn't show up in your profiler, but it decides how fast deploys run, how fast image-heavy pages render, and whether your server dies a confusing death at 50% disk usage. Start with the inode check — it's free and it catches the worst failure mode. Then decide deliberately: local SSD + CDN while you're small, object storage the moment files exceed a million or the second node joins the cluster, and database storage only when you have no other way to share media.&lt;/p&gt;

&lt;p&gt;The storage decision is a one-way door for your ops setup — pick the one that scales with the catalog, not the one that's easiest today.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
      <category>ecommerce</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Magento 2 Wishlist Performance: Taming the N+1 Query Beast</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Sun, 16 Aug 2026 09:04:28 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-wishlist-performance-taming-the-n1-query-beast-3me1</link>
      <guid>https://dev.to/magevanta/magento-2-wishlist-performance-taming-the-n1-query-beast-3me1</guid>
      <description>&lt;p&gt;The wishlist is one of the most under-profiled features in Magento 2. It's always there, nobody thinks about it, and then one day a customer with a 60-item wishlist opens their account page and the server takes four seconds to render it. Worse: the sidebar widget loads a version of that same data on &lt;em&gt;every&lt;/em&gt; page for logged-in customers.&lt;/p&gt;

&lt;p&gt;This post breaks down exactly what happens when Magento renders a wishlist, where the N+1 queries hide, how to measure the damage, and which fixes actually move the needle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Wishlist Is Expensive by Design
&lt;/h2&gt;

&lt;p&gt;A wishlist item isn't just a row in &lt;code&gt;wishlist_item&lt;/code&gt;. When Magento renders the wishlist page, it builds a &lt;strong&gt;collection of wishlist items joined with product data&lt;/strong&gt;, and then hands each item to a chain of column blocks (&lt;code&gt;Image&lt;/code&gt;, &lt;code&gt;Info&lt;/code&gt;, &lt;code&gt;AddToCart&lt;/code&gt;, &lt;code&gt;Price&lt;/code&gt;, &lt;code&gt;Remove&lt;/code&gt;, ...). Each of those blocks talks to product services — per item.&lt;/p&gt;

&lt;p&gt;The naive query flow for a wishlist with &lt;em&gt;N&lt;/em&gt; items looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;1 query&lt;/strong&gt; — base &lt;code&gt;wishlist_item&lt;/code&gt; collection, join on &lt;code&gt;catalog_product_entity&lt;/code&gt; and store-level attributes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;N queries&lt;/strong&gt; — stock / salable checks (each item's &lt;code&gt;isSalable()&lt;/code&gt; resolves because the Add to Cart button needs it)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;N queries&lt;/strong&gt; — final price lookups, including catalog price rule checks per item&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;N queries&lt;/strong&gt; — option and image/media gallery lookups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;N queries&lt;/strong&gt; — additional attribute loads per item (when attributes weren't joined in the collection)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On a 50-item wishlist, you're easily looking at &lt;strong&gt;150–300+ queries&lt;/strong&gt; for a single page. With configurable products in the list, the configured variant gets resolved per item too, pushing the count toward &lt;strong&gt;500+&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you've read the inventory reservation post, you already know where part of this goes: every salable check touches &lt;code&gt;inventory_reservation&lt;/code&gt; with a &lt;code&gt;SUM(quantity)&lt;/code&gt; for that SKU and stock. Multiply that by N items, and that "innocent" wishlist page is hammering one of the largest tables in your database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Culprit You're Ignoring: The Sidebar
&lt;/h2&gt;

&lt;p&gt;Before optimizing the wishlist page itself, check whether you're paying the wishlist tax on &lt;strong&gt;every page&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Magento's default layout registers the wishlist sidebar block (&lt;code&gt;Magento\Wishlist\Block\Customer\Sidebar&lt;/code&gt;, block name &lt;code&gt;wishlist_sidebar&lt;/code&gt;) in the &lt;code&gt;sidebar.additional&lt;/code&gt; container — which renders on &lt;strong&gt;every page&lt;/strong&gt; for logged-in customers. Its template loads the customer's wishlist item collection to show the last items. In other words:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A customer with a big wishlist triggers a wishlist item collection load (with product joins) on &lt;em&gt;every single page view&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If your logged-in traffic is meaningful, profile a simple account/shopping page: you'll often find wishlist-related queries on requests that have nothing to do with wishlists. The header wishlist counter (the &lt;code&gt;wishlist&lt;/code&gt; customer data section) does the same thing — it loads the full collection just to count items.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Measure the Damage
&lt;/h2&gt;

&lt;p&gt;Don't guess. Profile 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 dev:query-log:enable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open the wishlist page as a logged-in customer and grep for the wishlist signature:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"wishlist_item"&lt;/span&gt; var/log/query.log | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"inventory_reservation"&lt;/span&gt; var/log/query.log | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"catalog_product_entity"&lt;/span&gt; var/log/query.log | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a proper breakdown, use a profiler (Blackfire, Xdebug + Webgrind) and count the queries per wishlist item. A healthy wishlist page with 20 items should render in well under 100 queries. If you're at 10+ queries per item, you have N+1 patterns to kill.&lt;/p&gt;

&lt;p&gt;Also check your indexers:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;catalog_product_price&lt;/code&gt;, &lt;code&gt;catalogrule_product&lt;/code&gt; or &lt;code&gt;inventory&lt;/code&gt; are in &lt;strong&gt;Update by Schedule&lt;/strong&gt; mode with a backlog, the wishlist page (and anything else that resolves product state per item) gets dramatically slower, because it falls back to computing those states on the fly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fixes, Ranked by Impact
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Kill the Sidebar (if you're not using it)
&lt;/h3&gt;

&lt;p&gt;Most stores don't rely on the sidebar wishlist widget. Remove it and eliminate the per-page tax entirely. In your theme's layout:&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;!-- app/design/frontend/&amp;lt;Vendor&amp;gt;/&amp;lt;Theme&amp;gt;/Magento_Wishlist/layout/default.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;layout&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:View/Layout/etc/page_layout.xsd"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&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;"wishlist_sidebar"&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/layout&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Replace the Header Counter with a Light COUNT
&lt;/h3&gt;

&lt;p&gt;If you want to keep the counter badge, don't load the whole collection for it. The &lt;code&gt;Magento\Wishlist\CustomerData\Wishlist&lt;/code&gt; section source loads all items just to count them. Swap it for a cheap count query via a &lt;code&gt;di.xml&lt;/code&gt; preference or a plugin:&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;// Count only: SELECT COUNT(*) FROM wishlist_item WHERE wishlist_id = ? AND store_id = ?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One indexed COUNT beats a multi-join collection load on every request.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Batch the Stock Checks
&lt;/h3&gt;

&lt;p&gt;The Add to Cart column calls &lt;code&gt;isSalable()&lt;/code&gt; per item, which triggers the MSI salable-qty pipeline per SKU — including the &lt;code&gt;inventory_reservation&lt;/code&gt; SUM. Instead of fixing the core (which is risky), you can pre-resolve salability for &lt;em&gt;all&lt;/em&gt; items in one pass with a plugin on the wishlist block or a custom block override, using the batch API:&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;$stockStatuses&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;getStockStatusBySkus&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$skus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$stockId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loading stock status for 50 SKUs in one request is a handful of queries instead of 50 full salable checks.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Keep the Price Indexers Warm
&lt;/h3&gt;

&lt;p&gt;The per-item price block (&lt;code&gt;FinalPrice&lt;/code&gt; → &lt;code&gt;CatalogRulePrice&lt;/code&gt;) reads &lt;code&gt;catalogrule_product_price&lt;/code&gt;. When catalog rules are active and the index is stale or missing rows, Magento computes rule prices per product on the spot — a classic N+1 multiplier. Ensure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;catalog_product_price&lt;/code&gt; and &lt;code&gt;catalogrule_product&lt;/code&gt; run on schedule (or manually after rule changes)&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;price index is actually up to date before traffic hours&lt;/strong&gt;, not "scheduled" with a 2-day backlog&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Paginate and Limit
&lt;/h3&gt;

&lt;p&gt;The wishlist page default limit is generous. If your customers build huge wishlists, reduce the per-page item count (Layout/UI component or &lt;code&gt;wishlist/general&lt;/code&gt; pagination settings). Fewer items per request = fewer per-item queries, and customers rarely scroll 100 wishlist items.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Warm Up Product Images
&lt;/h3&gt;

&lt;p&gt;Each wishlist item renders a product thumbnail. On the first visit (or after a cache flush), Magento generates those images on the fly — N image generations during a page request is a request-time killer. Run a warmup in the deploy or cron:&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 catalog:images:resize
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  7. Revisit Configurable-Product Items
&lt;/h3&gt;

&lt;p&gt;Configurable products in a wishlist force Magento to resolve the selected child product per item (price, image, options). If configurable wishlist usage is heavy, consider a custom renderer that reuses the already-loaded options data instead of re-loading the child product per item. This is the biggest single-page win when your wishlists are dominated by configurable SKUs.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Note on Table Growth
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wishlist_item&lt;/code&gt; and &lt;code&gt;wishlist_item_option&lt;/code&gt; accumulate leftovers: items removed from the list leave orphaned option rows, and abandoned lists persist. On very large installs:&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;-- Orphaned options from removed items&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;wishlist_item_option&lt;/span&gt; &lt;span class="n"&gt;o&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;wishlist_item&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_item_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_item_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wishlist_item_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean those up periodically (off-peak, in batches) and ensure &lt;code&gt;wishlist_item.wishlist_id&lt;/code&gt; is indexed — with multi-store setups, also confirm &lt;code&gt;store_id&lt;/code&gt; filtering isn't forcing scans.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Remove &lt;code&gt;wishlist_sidebar&lt;/code&gt; unless actively used&lt;/li&gt;
&lt;li&gt;[ ] Replace the header counter with a COUNT query&lt;/li&gt;
&lt;li&gt;[ ] Batch stock/salable resolution instead of per-item &lt;code&gt;isSalable()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Keep &lt;code&gt;catalog_product_price&lt;/code&gt; / &lt;code&gt;catalogrule_product&lt;/code&gt; indexes current&lt;/li&gt;
&lt;li&gt;[ ] Reduce default wishlist pagination for big lists&lt;/li&gt;
&lt;li&gt;[ ] Warm up product images after deploys&lt;/li&gt;
&lt;li&gt;[ ] Profile before/after: query count per item should drop to &amp;lt; 2–3&lt;/li&gt;
&lt;li&gt;[ ] Schedule cleanup of orphaned &lt;code&gt;wishlist_item_option&lt;/code&gt; rows&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The wishlist is a textbook N+1 machine: one collection load multiplied by per-item stock, price, image, and option lookups — and the sidebar multiplies that tax across every page for logged-in customers. The good news is the fixes are surgical: remove the sidebar, lighten the counter, batch the stock checks, keep indexes warm, and paginate. None of them touch checkout or core business logic, so the risk/reward ratio is excellent.&lt;/p&gt;

&lt;p&gt;Profile first, kill the biggest multiplier first (usually the sidebar), and re-measure. A wishlist page that renders in 400 queries can comfortably drop to 50 — and your logged-in customers will feel it on every page, not just the wishlist.&lt;/p&gt;

</description>
      <category>magento</category>
    </item>
    <item>
      <title>Magento 2 Inventory Reservation Performance: Fixing the Silent Checkout Killer</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Sat, 15 Aug 2026 09:03:07 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-inventory-reservation-performance-fixing-the-silent-checkout-killer-h56</link>
      <guid>https://dev.to/magevanta/magento-2-inventory-reservation-performance-fixing-the-silent-checkout-killer-h56</guid>
      <description>&lt;p&gt;If you're running Magento 2 with MSI (Multi-Source Inventory) enabled — and since Magento 2.4 it's the default — you have a silent performance killer lurking in your database. The &lt;code&gt;inventory_reservation&lt;/code&gt; table grows without bound, and every single cart operation hits it. This post walks through why this table becomes a bottleneck, how to measure the impact, and concrete steps to fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Inventory Reservations Work
&lt;/h2&gt;

&lt;p&gt;When a customer adds a product to their cart, Magento doesn't immediately decrement stock. Instead, it creates a &lt;strong&gt;reservation&lt;/strong&gt; — a record in &lt;code&gt;inventory_reservation&lt;/code&gt; that says "this quantity is tentatively reserved for this order." The actual stock deduction happens later, when the order is placed and the shipment is processed.&lt;/p&gt;

&lt;p&gt;The flow looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add to cart&lt;/strong&gt; → &lt;code&gt;placeReservation&lt;/code&gt; writes a negative reservation record&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Place order&lt;/strong&gt; → reservation is linked to the order&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship order&lt;/strong&gt; → &lt;code&gt;inventory_source_item&lt;/code&gt; is decremented, reservation should be compensated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compensation reservation&lt;/strong&gt; → a positive record that cancels out the original negative one&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In theory, reservations are transient. They exist to bridge the gap between cart and shipment. In practice, they accumulate forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Unbounded Growth
&lt;/h2&gt;

&lt;p&gt;Here's what happens in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Orders that are &lt;strong&gt;canceled&lt;/strong&gt; leave orphaned negative reservations&lt;/li&gt;
&lt;li&gt;Orders that &lt;strong&gt;fail during checkout&lt;/strong&gt; leave reservations that are never compensated&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial shipments&lt;/strong&gt; create partial compensation records&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote conversions&lt;/strong&gt; that error out mid-process leave dangling reservations&lt;/li&gt;
&lt;li&gt;Re-indexing, re-stocking, and admin edits can create duplicate records&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After 6–12 months of moderate traffic, the &lt;code&gt;inventory_reservation&lt;/code&gt; table routinely hits &lt;strong&gt;several million rows&lt;/strong&gt;. I've seen tables with 10M+ rows on stores doing 200 orders/day.&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;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;inventory_reservation&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 4,872,341 rows on a store running 8 months&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;inventory_reservation&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;DATE_SUB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 4,710,882 — 96.7% of rows are older than 30 days&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why This Kills Checkout Performance
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;addToCart&lt;/code&gt; and &lt;code&gt;placeOrder&lt;/code&gt; call executes this query pattern:&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;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&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;inventory_reservation&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'WS11'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'WS12'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the table has millions of rows and no useful index on &lt;code&gt;sku&lt;/code&gt;, this becomes a full table scan. On a busy MySQL instance, that's 200–800ms per cart operation — &lt;strong&gt;per product&lt;/strong&gt;. A cart with 5 items can add 2–4 seconds to the checkout flow.&lt;/p&gt;

&lt;p&gt;The problem compounds under load because MySQL's InnoDB buffer pool can't keep the full table in memory. You get disk I/O, lock contention, and eventually lock timeouts that surface as 502 errors or failed checkouts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Measure the Impact
&lt;/h2&gt;

&lt;p&gt;Before fixing anything, quantify the problem on your installation:&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;-- Table size&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;table_schema&lt;/span&gt;&lt;span class="p"&gt;,&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;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;data_mb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;ROUND&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index_length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_mb&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="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'inventory_reservation'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Query performance&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events_statements_summary_by_digest&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;digest_text&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%inventory_reservation%'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sum_timer_wait&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a typical affected store, you'll see the &lt;code&gt;SUM(quantity)&lt;/code&gt; query in the top 5 slowest queries, with average execution times of 300–600ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Add a Composite Index
&lt;/h2&gt;

&lt;p&gt;The default schema has a primary key on &lt;code&gt;reservation_id&lt;/code&gt; but no useful index for the query pattern that Magento actually uses. Add a composite index:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;inventory_reservation&lt;/span&gt;
&lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_sku_created&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single change can reduce the &lt;code&gt;SUM(quantity)&lt;/code&gt; query from 600ms to under 20ms on a 5M row table, because MySQL can use a covering index scan instead of a full table scan.&lt;/p&gt;

&lt;p&gt;For very large tables, use &lt;code&gt;pt-online-schema-change&lt;/code&gt; or MySQL 8's instant DDL to add the index without downtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pt-online-schema-change &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--alter&lt;/span&gt; &lt;span class="s2"&gt;"ADD INDEX idx_sku_created (sku, created_at)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;D&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_db,t&lt;span class="o"&gt;=&lt;/span&gt;inventory_reservation &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--execute&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Clean Up Old Reservations
&lt;/h2&gt;

&lt;p&gt;Reservations older than your order lifecycle (typically 30–90 days) can be safely compensated and archived. Here's a safe cleanup approach:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="c1"&gt;// Clean up reservations older than X days&lt;/span&gt;
&lt;span class="c1"&gt;// Only process SKUs where order is complete or canceled&lt;/span&gt;

&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Vendor\InventoryCleanup\Cron&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Magento\Framework\DB\Adapter\AdapterInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Magento\Framework\App\ResourceConnection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReservationCleanup&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;RESERVATION_TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'inventory_reservation'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;ORDER_TABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'sales_order'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;RETENTION_DAYS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;ResourceConnection&lt;/span&gt; &lt;span class="nv"&gt;$resource&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$connection&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;resource&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getConnection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nv"&gt;$reservationTable&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;resource&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTableName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RESERVATION_TABLE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$orderTable&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;resource&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTableName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;ORDER_TABLE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="c1"&gt;// Find SKUs with completed/canceled orders older than retention period&lt;/span&gt;
        &lt;span class="nv"&gt;$skuSelect&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&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;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'r'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$reservationTable&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'sku'&lt;/span&gt;&lt;span class="p"&gt;]&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;joinInner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'o'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$orderTable&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="s1"&gt;'r.metadata LIKE CONCAT("%", o.increment_id, "%")'&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r.created_at &amp;lt; DATE_SUB(NOW(), INTERVAL %d DAY)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RETENTION_DAYS&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;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'o.status IN (?)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'complete'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'canceled'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'closed'&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;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'r.sku'&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;having&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'SUM(r.quantity) = 0'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$staleSkus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchCol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$skuSelect&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$staleSkus&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Delete compensated reservations for these SKUs&lt;/span&gt;
        &lt;span class="nv"&gt;$connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nv"&gt;$reservationTable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="s1"&gt;'sku IN (?)'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$staleSkus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'created_at &amp;lt; DATE_SUB(NOW(), INTERVAL ? DAY)'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;RETENTION_DAYS&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schedule this as a nightly cron:&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;group&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"inventory_cleanup"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;job&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"vendor_inventory_reservation_cleanup"&lt;/span&gt;
              &lt;span class="na"&gt;instance=&lt;/span&gt;&lt;span class="s"&gt;"Vendor\InventoryCleanup\Cron\ReservationCleanup"&lt;/span&gt;
              &lt;span class="na"&gt;method=&lt;/span&gt;&lt;span class="s"&gt;"execute"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;schedule&amp;gt;&lt;/span&gt;0 2 * * *&lt;span class="nt"&gt;&amp;lt;/schedule&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/job&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/group&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;h2&gt;
  
  
  Step 4: Optimize Source Selection
&lt;/h2&gt;

&lt;p&gt;The source selection algorithm (SSA) runs on every order, determining which warehouse fulfills which item. The default &lt;code&gt;Priority&lt;/code&gt; algorithm iterates through all sources in priority order, which is O(n_sources × n_items). For stores with many sources, this becomes expensive.&lt;/p&gt;

&lt;p&gt;You can implement a custom source selection algorithm that batches by availability:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Vendor\Inventory\Model\SourceSelection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Magento\InventorySourceSelectionApi\Model\SourceSelectionInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Magento\InventorySourceSelectionApi\Api\Data\SourceSelectionResultInterfaceFactory&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Magento\InventoryApi\Api\SourceItemRepositoryInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Magento\Framework\Api\SearchCriteriaBuilder&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OptimizedSourceSelection&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;SourceSelectionInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;SourceItemRepositoryInterface&lt;/span&gt; &lt;span class="nv"&gt;$sourceItemRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;SearchCriteriaBuilder&lt;/span&gt; &lt;span class="nv"&gt;$searchCriteriaBuilder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="kt"&gt;SourceSelectionResultInterfaceFactory&lt;/span&gt; &lt;span class="nv"&gt;$resultFactory&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$sources&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;SourceSelectionResultInterface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Batch-load all source items in a single query&lt;/span&gt;
        &lt;span class="nv"&gt;$skus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSku&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$items&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="nv"&gt;$criteria&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;searchCriteriaBuilder&lt;/span&gt;
            &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sku'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$skus&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'in'&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;addFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="nv"&gt;$sourceItems&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;sourceItemRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getList&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$criteria&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;getItems&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

        &lt;span class="c1"&gt;// Group by source, sort by quantity descending for best-fit packing&lt;/span&gt;
        &lt;span class="nv"&gt;$sourceMap&lt;/span&gt; &lt;span class="o"&gt;=&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;$sourceItems&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$sourceCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSourceCode&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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourceMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$sourceCode&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$sourceMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$sourceCode&lt;/span&gt;&lt;span class="p"&gt;]&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;$sourceMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$sourceCode&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSku&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQuantity&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Greedy allocation: pick source with most stock first&lt;/span&gt;
        &lt;span class="nb"&gt;uksort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourceMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourceMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;array_sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$sourceMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$a&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;

        &lt;span class="nv"&gt;$sourceSelectionItems&lt;/span&gt; &lt;span class="o"&gt;=&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;$sourceMap&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$sourceCode&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$stockBySku&lt;/span&gt;&lt;span class="p"&gt;)&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;$items&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$sku&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSku&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nv"&gt;$requested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQty&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="nv"&gt;$available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$stockBySku&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$sku&lt;/span&gt;&lt;span class="p"&gt;]&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;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$available&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nv"&gt;$requested&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nv"&gt;$sourceSelectionItems&lt;/span&gt;&lt;span class="p"&gt;[]&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;sourceSelectionItemFactory&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="s1"&gt;'sourceCode'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sourceCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="s1"&gt;'sku'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="s1"&gt;'qty'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$requested&lt;/span&gt;
                    &lt;span class="p"&gt;]);&lt;/span&gt;
                    &lt;span class="nv"&gt;$stockBySku&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$sku&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nv"&gt;$requested&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                    &lt;span class="nv"&gt;$item&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setQty&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;// fulfilled&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;return&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;resultFactory&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="s1"&gt;'sourceSelectionItems'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$sourceSelectionItems&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;Register it in &lt;code&gt;di.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;config&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\InventorySourceSelectionApi\Model\SourceSelectionAlgorithmFactory"&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;"algorithms"&lt;/span&gt; &lt;span class="na"&gt;xsi:type=&lt;/span&gt;&lt;span class="s"&gt;"array"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;item&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"optimized"&lt;/span&gt; &lt;span class="na"&gt;xsi:type=&lt;/span&gt;&lt;span class="s"&gt;"array"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;item&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"code"&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;optimized&lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;item&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"title"&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;Optimized Batch Allocation&lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
                    &lt;span class="nt"&gt;&amp;lt;item&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"class"&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;Vendor\Inventory\Model\SourceSelection\OptimizedSourceSelection&lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
                &lt;span class="nt"&gt;&amp;lt;/item&amp;gt;&lt;/span&gt;
            &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;/type&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;Then select it in Stores → Configuration → Catalog → Inventory → Source Selection Algorithm.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Disable Reservations for Non-Shipping Products
&lt;/h2&gt;

&lt;p&gt;Not every product type needs reservations. Virtual products, downloadable products, and gift cards don't have physical stock to track. Yet Magento creates reservations for them anyway. You can skip reservation creation for these types:&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="cp"&gt;&amp;lt;?php&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict_types&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="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;Vendor\Inventory\Plugin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Magento\InventoryReservationsApi\Model\AppendReservationsInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SkipNonPhysicalReservations&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SKIP_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'virtual'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'downloadable'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'giftcard'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nc"&gt;\Magento\Catalog\Api\ProductRepositoryInterface&lt;/span&gt; &lt;span class="nv"&gt;$productRepository&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;beforeAppend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;AppendReservationsInterface&lt;/span&gt; &lt;span class="nv"&gt;$subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$reservations&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$reservations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$reservation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$product&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;productRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$reservation&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getSku&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getTypeId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SKIP_TYPES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Exception&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keep if we can't determine type&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This introduces a product load per SKU. Cache the result or use a batch query in production. For high-volume stores, prefer extending &lt;code&gt;\Magento\InventoryReservations\Model\AppendReservations&lt;/code&gt; directly with a batch-aware filter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Benchmark: Before and After
&lt;/h2&gt;

&lt;p&gt;On a store with 5M reservation rows, 8 sources, and ~200 orders/day:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add to cart (P95)&lt;/td&gt;
&lt;td&gt;1,200ms&lt;/td&gt;
&lt;td&gt;180ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Place order (P95)&lt;/td&gt;
&lt;td&gt;3,400ms&lt;/td&gt;
&lt;td&gt;620ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;inventory_reservation&lt;/code&gt; rows&lt;/td&gt;
&lt;td&gt;5.2M&lt;/td&gt;
&lt;td&gt;380K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB CPU during peak&lt;/td&gt;
&lt;td&gt;85%&lt;/td&gt;
&lt;td&gt;40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checkout failure rate&lt;/td&gt;
&lt;td&gt;2.1%&lt;/td&gt;
&lt;td&gt;0.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The index alone accounts for ~60% of the improvement. Cleanup accounts for ~30%. Source selection optimization accounts for the remaining ~10%.&lt;/p&gt;

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

&lt;p&gt;Set up alerts on these metrics:&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;-- Reservation table growth rate (run daily)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&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;inventory_reservation&lt;/span&gt;
                   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;DATE_SUB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;daily_growth&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;inventory_reservation&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Uncompensated reservations (should be near zero)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;uncompensated&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;inventory_reservation&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;ABS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;uncompensated&lt;/code&gt; grows steadily, you have a compensation bug — orders are being placed but reservations aren't being cleared after shipment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Strategy
&lt;/h2&gt;

&lt;p&gt;For maximum impact, combine all steps into a maintenance module:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Index&lt;/strong&gt; — add &lt;code&gt;idx_sku_created&lt;/code&gt; on install&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleanup cron&lt;/strong&gt; — nightly at 02:00, 30-day retention&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source selection&lt;/strong&gt; — batch-loaded greedy algorithm&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Type filtering&lt;/strong&gt; — skip non-physical product reservations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring&lt;/strong&gt; — alert when uncompensated reservations exceed threshold&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This combination transforms MSI from a liability into a reliable system. The &lt;code&gt;inventory_reservation&lt;/code&gt; table stays under 500K rows, checkout latency drops by 80%, and your database stops being the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Inventory reservations are Magento's answer to overselling, but the default implementation is a performance debt that compounds over time. The fix isn't one silver bullet — it's the combination of proper indexing, scheduled cleanup, smarter source selection, and type-aware filtering. Apply all five steps and measure the difference. Your checkout flow will thank you.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
      <category>php</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Magento 2 Payment Gateway Performance: Reducing Checkout Latency</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Fri, 14 Aug 2026 09:06:47 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-payment-gateway-performance-reducing-checkout-latency-3fpl</link>
      <guid>https://dev.to/magevanta/magento-2-payment-gateway-performance-reducing-checkout-latency-3fpl</guid>
      <description>&lt;p&gt;The checkout is the single most critical performance bottleneck in any Magento 2 store. A customer who has browsed your catalog, added items to their cart, and reached the payment step is &lt;em&gt;one click away from revenue&lt;/em&gt;. Yet payment gateway APIs routinely add 2–5 seconds of latency to that final click — and every 100ms of delay measurably impacts conversion rates.&lt;/p&gt;

&lt;p&gt;In this guide, we'll break down every layer of payment performance in Magento 2: from the gateway integration itself to how payment methods are loaded on the checkout page, how rate quotes are cached, and how you can offload slow operations to background processes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Payment Latency Comes From
&lt;/h2&gt;

&lt;p&gt;Before optimizing, you need to understand the four main sources of payment-related latency in Magento 2:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gateway API calls&lt;/strong&gt; — Authorize.net, Stripe, Adyen, or Mollie all require HTTP round-trips to external servers. These calls are synchronous by default and block the checkout response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment method availability checks&lt;/strong&gt; — Magento evaluates which payment methods are available for the current quote on every checkout page load. Each method can trigger its own logic (customer group, billing address, cart total checks).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate quote requests&lt;/strong&gt; — Many gateways fetch transaction fees or currency conversion rates on every checkout load, not just when the customer selects "Place Order."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment method rendering&lt;/strong&gt; — Each payment method injects its own JS component via &lt;code&gt;payment-methods.js&lt;/code&gt;. Heavy JavaScript bundles for payment forms (iFrames, validation libraries, 3DS widgets) add to frontend rendering time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The good news: every single one of these is fixable.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Cache Gateway Rate Quotes
&lt;/h2&gt;

&lt;p&gt;Most payment gateways provide a "rate quote" or "fee quote" endpoint that calculates transaction costs based on cart total, currency, and customer group. The mistake most integrations make is calling this endpoint on every checkout page load.&lt;/p&gt;

&lt;p&gt;If your cart total hasn't changed and your currency hasn't changed, the rate quote hasn't changed either. Cache it.&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;// In your payment method model or gateway client&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getTransactionFee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Quote&lt;/span&gt; &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$cacheKey&lt;/span&gt; &lt;span class="o"&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;'pay_gateway_fee_%s_%s_%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQuoteCurrencyCode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getGrandTotal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&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="nv"&gt;$cachedFee&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;cache&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="nv"&gt;$cacheKey&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$cachedFee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$fee&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;gatewayClient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;fetchTransactionFee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$quote&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="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nv"&gt;$cacheKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'payment_fee'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'quote_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
        &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="c1"&gt;// 1 hour TTL&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$fee&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;The cache tag &lt;code&gt;quote_&lt;/code&gt; followed by the quote ID ensures the fee is invalidated when the cart changes (adding/removing items updates the quote, which triggers tag-based cache invalidation). This alone can eliminate hundreds of milliseconds per checkout load.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Lazy-Load Payment Method Components
&lt;/h2&gt;

&lt;p&gt;Magento 2's checkout renders all available payment methods on page load via the &lt;code&gt;Magento_Checkout/js/view/payment&lt;/code&gt; UI component. Each method registers its renderer, validation rules, and potentially heavy JavaScript dependencies — even if the customer never selects that method.&lt;/p&gt;

&lt;p&gt;For stores with 5+ payment methods (common in European markets: iDeal, Bancontact,信用卡, PayPal, Klarna...), this adds significant JavaScript parse and execution time.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;code-split payment method renderers&lt;/strong&gt; and load them on demand.&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="c1"&gt;// Instead of requiring all payment renderers upfront in payment-methods.js&lt;/span&gt;
&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;uiComponent&lt;/span&gt;&lt;span class="dl"&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;Magento_Checkout/js/model/payment/renderer-list&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rendererList&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;use strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Component&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;:&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_super&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="c1"&gt;// Only register the default/preferred method eagerly&lt;/span&gt;
            &lt;span class="nx"&gt;rendererList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ideal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;YourVendor_Payment/js/view/payment/method-renderer/ideal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
            &lt;span class="p"&gt;});&lt;/span&gt;

            &lt;span class="c1"&gt;// Lazy-load other methods on selection&lt;/span&gt;
            &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lazyPaymentMethods&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;renderer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nx"&gt;rendererList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                        &lt;span class="na"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;component&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;return&lt;/span&gt; &lt;span class="k"&gt;this&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;require()&lt;/code&gt; with a dynamic callback defers the JavaScript load until the component is actually needed. For payment methods like Klarna or PayPal that bundle large SDKs, this can save 200–400KB of JavaScript on initial checkout render.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Set Aggressive Gateway Timeouts
&lt;/h2&gt;

&lt;p&gt;A payment gateway that takes 30 seconds to respond is worse than one that fails immediately. Customers will abandon the checkout either way, but a fast failure lets you fall back to a secondary gateway or show a retry prompt.&lt;/p&gt;

&lt;p&gt;Configure your gateway HTTP client with strict timeouts:&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;// In your gateway client configuration (di.xml or module config)&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"YourVendor\Payment\Model\Gateway\Http\Client"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;argument&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"config"&lt;/span&gt; &lt;span class="n"&gt;xsi&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"timeout"&lt;/span&gt; &lt;span class="n"&gt;xsi&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"connect_timeout"&lt;/span&gt; &lt;span class="n"&gt;xsi&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"number"&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;argument&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And if you're using Guzzle or Symfony HTTP client directly:&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;$client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\GuzzleHttp\Client&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="s1"&gt;'timeout'&lt;/span&gt;         &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;// Total request timeout&lt;/span&gt;
    &lt;span class="s1"&gt;'connect_timeout'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// TCP connection timeout&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$client&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$gatewayUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'json'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payload&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;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\GuzzleHttp\Exception\ConnectException&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Log and fallback — don't let the customer wait&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;logger&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Payment gateway timeout'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'gateway'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$gatewayUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'error'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$e&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getMessage&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\Magento\Framework\Exception\LocalizedException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Payment service unavailable. Please try again or choose a different payment method.'&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;The key insight: &lt;strong&gt;a 10-second timeout is your safety net, not your target&lt;/strong&gt;. If your gateway is consistently taking more than 3–4 seconds, you should investigate failover or async strategies (covered below).&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Async Authorization for Supported Gateways
&lt;/h2&gt;

&lt;p&gt;Some payment gateways — notably Mollie, Adyen, and Stripe — support async payment authorization. Instead of blocking the customer's browser while the gateway processes the payment, you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a payment intent/transaction on the frontend (fast API call)&lt;/li&gt;
&lt;li&gt;Redirect the customer to the success page immediately&lt;/li&gt;
&lt;li&gt;The gateway sends a webhook to confirm the payment asynchronously&lt;/li&gt;
&lt;li&gt;Magento processes the webhook via a message queue consumer&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the pattern:&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;// Frontend controller — create payment intent, return immediately&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$quote&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;checkoutSession&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQuote&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Create payment intent (fast — ~500ms)&lt;/span&gt;
    &lt;span class="nv"&gt;$intent&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;gatewayClient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;createPaymentIntent&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'amount'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getGrandTotal&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'currency'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getQuoteCurrencyCode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'metadata'&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;'quote_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt;
    &lt;span class="p"&gt;]);&lt;/span&gt;

    &lt;span class="c1"&gt;// Place order as pending&lt;/span&gt;
    &lt;span class="nv"&gt;$order&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="nf"&gt;placeOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$intent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Sales\Model\Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;STATE_PENDING_PAYMENT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Return intent to frontend for redirect&lt;/span&gt;
    &lt;span class="k"&gt;return&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;jsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="s1"&gt;'intent_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$intent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'redirect_url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$intent&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRedirectUrl&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Webhook consumer — process async payment confirmation&lt;/span&gt;
&lt;span class="c1"&gt;// In etc/communication.xml and etc/queue_consumer.xml&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;processPaymentWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$paymentData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$order&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;orderRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getByIncrementId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$paymentData&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'order_id'&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="nv"&gt;$paymentData&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'authorized'&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="n"&gt;orderManagement&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;processPayment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$paymentData&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Sales\Model\Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;STATE_PROCESSING&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$order&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Sales\Model\Order&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;STATE_CANCELED&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="n"&gt;orderRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$order&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;This pattern moves the slowest operation (gateway processing time) completely out of the customer's checkout flow. The customer sees the success page within 1–2 seconds of clicking "Place Order."&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Optimize Payment Method Availability Checks
&lt;/h2&gt;

&lt;p&gt;Magento 2 evaluates &lt;code&gt;isActive()&lt;/code&gt; and &lt;code&gt;isAvailable()&lt;/code&gt; for every payment method on every checkout page load. For stores with many payment methods, this creates a cascade of checks — some involving database queries (customer group validation, billing address country validation, min/max cart total validation).&lt;/p&gt;

&lt;p&gt;Profile these checks:&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;# Enable profiler for the checkout page&lt;/span&gt;
bin/magento dev:profiler:enable html
&lt;span class="c"&gt;# Load checkout, then check the profiler output&lt;/span&gt;
&lt;span class="c"&gt;# Look for "PaymentMethod" entries in the call tree&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common bottlenecks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Country validation&lt;/strong&gt; that queries &lt;code&gt;directory_country&lt;/code&gt; on every load — cache the country list per store view&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customer group rules&lt;/strong&gt; that run separate queries per payment method — batch-load customer group data once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Min/max order total checks&lt;/strong&gt; that re-read quote totals — use the already-calculated &lt;code&gt;quote.grand_total&lt;/code&gt; instead of recalculating&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have a custom payment method, ensure its &lt;code&gt;isAvailable()&lt;/code&gt; method is O(1) and doesn't trigger any database queries:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;isAvailable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Quote\Api\Data\CartInterface&lt;/span&gt; &lt;span class="nv"&gt;$quote&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Use data already on the quote — no extra queries&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$quote&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getItemsCount&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$grandTotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getGrandTotal&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="nv"&gt;$grandTotal&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&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;minOrderTotal&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$grandTotal&lt;/span&gt; &lt;span class="o"&gt;&amp;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;maxOrderTotal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Country check from cached data&lt;/span&gt;
    &lt;span class="nv"&gt;$countryCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$quote&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getBillingAddress&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;getCountryId&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="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$countryCode&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="n"&gt;allowedCountries&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;h2&gt;
  
  
  6. Payment Method Template Optimization
&lt;/h2&gt;

&lt;p&gt;Payment method HTML templates are rendered inline on the checkout page. For methods that include iFrame-based card inputs (e.g., Stripe Elements), these templates can be heavy.&lt;/p&gt;

&lt;p&gt;Optimization strategies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Defer 3DS widget loading&lt;/strong&gt; — only load the 3DS challenge iframe when the customer triggers the "Place Order" action&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preconnect to payment gateway domains&lt;/strong&gt; — add &lt;code&gt;&amp;lt;link rel="preconnect"&amp;gt;&lt;/code&gt; for gateway API endpoints to warm up DNS/TLS connections before they're needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline critical form elements&lt;/strong&gt; — the card input fields should be in the initial HTML, while validation scripts and style assets can load asynchronously&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding preconnect hints is a zero-effort win. Add this to your theme's &lt;code&gt;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;page&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="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;head&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;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://api.stripe.com"&lt;/span&gt; &lt;span class="nt"&gt;/&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;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://js.stripe.com"&lt;/span&gt; &lt;span class="nt"&gt;/&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;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://api.mollie.com"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- Add your gateway domains --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/page&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For checkout-specific preconnects, create &lt;code&gt;checkout_index_index.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;page&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="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;head&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;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://api.stripe.com"&lt;/span&gt; &lt;span class="nt"&gt;/&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;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://m.stripe.com"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/page&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Measure: Payment Performance Dashboard
&lt;/h2&gt;

&lt;p&gt;You can't optimize what you don't measure. Create a simple admin dashboard widget that tracks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gateway response time&lt;/strong&gt; (average, P95, P99) — measure from your gateway client&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gateway error rate&lt;/strong&gt; — timeouts, 5xx responses, declined payments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checkout completion time&lt;/strong&gt; — from payment step render to order placement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment method selection distribution&lt;/strong&gt; — which methods customers actually use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Log gateway timing in your client:&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;$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;$response&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;gatewayClient&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$duration&lt;/span&gt; &lt;span class="o"&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="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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="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="s1"&gt;'Payment gateway response'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s1"&gt;'gateway'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;gatewayCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'endpoint'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getUri&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s1"&gt;'duration_ms'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$duration&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="s1"&gt;'status'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStatusCode&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;Forward these logs to a monitoring system (New Relic, Grafana, or even a simple cron-aggregated report) and set alerts for P95 latency above 3 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Payment performance is the last mile of your Magento 2 checkout — and the part where you have the &lt;em&gt;least&lt;/em&gt; control, because you're depending on external services. The strategies that work are the ones that minimize synchronous external calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache rate quotes&lt;/strong&gt; to avoid redundant gateway calls&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-load payment method JavaScript&lt;/strong&gt; to reduce frontend payload&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set aggressive timeouts&lt;/strong&gt; so fast failures beat slow hangs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use async authorization&lt;/strong&gt; where your gateway supports it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile and optimize &lt;code&gt;isAvailable()&lt;/code&gt; checks&lt;/strong&gt; to prevent silent database queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preconnect to gateway domains&lt;/strong&gt; for free TLS savings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitor everything&lt;/strong&gt; — gateway latency is not static, it degrades under load&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every 100 milliseconds you shave off payment processing translates directly to recovered revenue. Start with caching rate quotes and setting timeouts — those two changes take an afternoon and typically save 1–2 seconds per checkout.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
      <category>php</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Magento 2 Setup:Upgrade Performance — Speed Up Slow Deployments</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Thu, 13 Aug 2026 09:03:31 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-setupupgrade-performance-speed-up-slow-deployments-2jdg</link>
      <guid>https://dev.to/magevanta/magento-2-setupupgrade-performance-speed-up-slow-deployments-2jdg</guid>
      <description>&lt;p&gt;If you've ever watched &lt;code&gt;bin/magento setup:upgrade&lt;/code&gt; run for 20 minutes on a production database while your deployment pipeline ticks away, you know the pain. On large Magento 2 installations with hundreds of modules and millions of records, &lt;code&gt;setup:upgrade&lt;/code&gt; becomes the single biggest bottleneck in your CI/CD pipeline — often taking longer than the actual code deployment, static content generation, and cache warming combined.&lt;/p&gt;

&lt;p&gt;The good news? Most of that time is wasted on operations you can optimize, skip, or parallelize. Let's break down exactly what &lt;code&gt;setup:upgrade&lt;/code&gt; does, where it spends its time, and how to make it fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Setup:Upgrade Actually Does
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;setup:upgrade&lt;/code&gt; is Magento's schema and data migration tool. When you deploy new code, it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Checks module versions&lt;/strong&gt; — reads every module's &lt;code&gt;module.xml&lt;/code&gt; and &lt;code&gt;db_schema.xml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs schema upgrades&lt;/strong&gt; — executes &lt;code&gt;InstallSchema&lt;/code&gt;, &lt;code&gt;UpgradeSchema&lt;/code&gt;, and declarative schema patches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs data patches&lt;/strong&gt; — executes &lt;code&gt;RecurringDataPatch&lt;/code&gt; and module-level data patches&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates &lt;code&gt;setup_module&lt;/code&gt; table&lt;/strong&gt; — records the new module version&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs DI compilation&lt;/strong&gt; — regenerates &lt;code&gt;generated/&lt;/code&gt; (if in production mode)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flushes cache types&lt;/strong&gt; — invalidates configs, layout, block_html, full_page&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 2 and 3 are where most of the time goes. If you have modules with poorly written data patches, N+1 queries in upgrade scripts, or schema operations that don't use batch inserts, &lt;code&gt;setup:upgrade&lt;/code&gt; can crawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Profile Before You Optimize
&lt;/h2&gt;

&lt;p&gt;Don't guess — measure. Run &lt;code&gt;setup:upgrade&lt;/code&gt; with the verbose flag and capture timestamps:&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 setup:upgrade &lt;span class="nt"&gt;--verbose&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;tee&lt;/span&gt; /tmp/setup-upgrade.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a timestamped log showing exactly which modules take the longest. Look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long gaps between module lines&lt;/strong&gt; — a single module's upgrade is slow&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repeating patterns&lt;/strong&gt; — a third-party module doing per-row inserts in a loop&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Data upgrade..." messages&lt;/strong&gt; — data patches running row-by-row instead of in bulk&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a deeper dive, enable MySQL's slow query log during the upgrade:&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;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;slow_query_log&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ON'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;long_query_time&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;setup:upgrade&lt;/code&gt;, then check &lt;code&gt;mysql.slow_log&lt;/code&gt; for queries taking more than a second. You'll often find the same few queries repeated hundreds of times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Eliminate Unnecessary Module Upgrades
&lt;/h2&gt;

&lt;p&gt;Not every deployment changes every module. If only 3 modules changed, why upgrade all 300?&lt;/p&gt;

&lt;p&gt;Magento doesn't give you a native &lt;code&gt;--only-module&lt;/code&gt; flag, but you can work around it. First, identify which modules actually changed version:&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;# Compare deployed versions vs new code versions&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/html
bin/magento module:status &lt;span class="nt"&gt;--enabled&lt;/span&gt; | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read &lt;/span&gt;module&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'setup_version="[0-9.]+"'&lt;/span&gt; app/code/&lt;span class="k"&gt;*&lt;/span&gt;/etc/module.xml 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'[0-9.]+'&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="nv"&gt;db_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;bin/magento setup:db:status &lt;span class="nt"&gt;--module&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$module&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'version: [0-9.]+'&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oP&lt;/span&gt; &lt;span class="s1"&gt;'[0-9.]+'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code_version&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$db_version&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$module&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="nv"&gt;$db_version&lt;/span&gt;&lt;span class="s2"&gt; → &lt;/span&gt;&lt;span class="nv"&gt;$code_version&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If fewer than 10 modules changed, consider running schema patches manually or using a targeted approach. For full automation, build this check into your deploy script and skip &lt;code&gt;setup:upgrade&lt;/code&gt; entirely when no modules changed version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Optimize Data Patches
&lt;/h2&gt;

&lt;p&gt;Data patches (&lt;code&gt;Patch/Schema&lt;/code&gt; and &lt;code&gt;Patch/Data&lt;/code&gt;) are the #1 culprit for slow upgrades. Common anti-patterns:&lt;/p&gt;

&lt;h3&gt;
  
  
  Anti-pattern 1: Per-row inserts in a loop
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BAD — runs 50,000 queries&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;$productIds&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$id&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="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'catalog_product_entity_int'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'attribute_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attrId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'entity_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'value'&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="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// GOOD — single batch insert&lt;/span&gt;
&lt;span class="nv"&gt;$rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$attrId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'attribute_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$attrId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'entity_id'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'value'&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="p"&gt;},&lt;/span&gt; &lt;span class="nv"&gt;$productIds&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="n"&gt;connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;insertMultiple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'catalog_product_entity_int'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Anti-pattern 2: Loading full collections
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BAD — loads 50K products into memory&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="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="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="mf"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// GOOD — use direct SQL or iterator&lt;/span&gt;
&lt;span class="nv"&gt;$select&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;connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&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;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'catalog_product_entity'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'entity_id'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nv"&gt;$iterator&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;connection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$select&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;$batch&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="nf"&gt;fetchBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$iterator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="mf"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Anti-pattern 3: Missing dependency declarations
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;PatchB&lt;/code&gt; depends on &lt;code&gt;PatchA&lt;/code&gt;, but the dependency isn't declared, Magento may run them in parallel or in the wrong order. The patch system uses &lt;code&gt;getDependencies()&lt;/code&gt; to sequence execution. Missing dependencies cause deadlocks on large installs.&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getDependencies&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;\Vendor\Module\Setup\Patch\Data\AddColumnToProducts&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="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;Audit your third-party modules' data patches — you'll be surprised how many have these anti-patterns. If vendor patches are slow, consider wrapping them or running them pre-deployment during off-peak hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Mark Already-Applied Patches (Skip Redundant Work)
&lt;/h2&gt;

&lt;p&gt;If you know certain data patches have already run and don't need to run again, you can mark them as applied in the database:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;setup_patches&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;patch_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Vendor&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;Module&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;Setup&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;Patch&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;Data&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;SlowPatch'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;patch_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'data'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patch_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'1.0.0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patch_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;DUPLICATE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;patch_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;patch_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This skips the patch on the next &lt;code&gt;setup:upgrade&lt;/code&gt; run. Only do this if you're certain the patch has been applied — check with your deployment logs first.&lt;/p&gt;

&lt;p&gt;For a safer approach, use Magento's &lt;code&gt;--safe-mode=1&lt;/code&gt; flag during staging deployments to verify patches before running them in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Declarative Schema Over Upgrade Scripts
&lt;/h2&gt;

&lt;p&gt;Declarative schema (&lt;code&gt;db_schema.xml&lt;/code&gt;) is significantly faster than &lt;code&gt;UpgradeSchema.php&lt;/code&gt; scripts because Magento generates a single diff and applies it in batch, rather than running arbitrary PHP per module.&lt;/p&gt;

&lt;p&gt;If you maintain custom modules, migrate from &lt;code&gt;UpgradeSchema.php&lt;/code&gt; to &lt;code&gt;db_schema.xml&lt;/code&gt;. The migration is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create &lt;code&gt;etc/db_schema.xml&lt;/code&gt; matching your current schema&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;&amp;lt;schema&amp;gt;&lt;/code&gt; declaration in &lt;code&gt;module.xml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;bin/magento setup:upgrade --convert-old-scripts=1&lt;/code&gt; to auto-generate the schema from existing upgrade scripts&lt;/li&gt;
&lt;li&gt;Review and test the generated &lt;code&gt;db_schema.xml&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Magento handles the diff, generates &lt;code&gt;CREATE TABLE&lt;/code&gt;, &lt;code&gt;ALTER TABLE&lt;/code&gt;, and &lt;code&gt;DROP TABLE&lt;/code&gt; statements automatically. No PHP execution needed per upgrade cycle — Magento compares the declarative schema against the database and only runs the delta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Skip DI Compilation During Upgrade
&lt;/h2&gt;

&lt;p&gt;If you generate compiled DI separately (in your CI pipeline), you don't need &lt;code&gt;setup:upgrade&lt;/code&gt; to do it. Run:&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 setup:upgrade &lt;span class="nt"&gt;--keep-generated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This skips the &lt;code&gt;generated/&lt;/code&gt; regeneration step, which can save several minutes on large installs. Just make sure you run &lt;code&gt;setup:di:compile&lt;/code&gt; in your CI pipeline before deploying:&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;# In your CI pipeline&lt;/span&gt;
bin/magento setup:di:compile
&lt;span class="c"&gt;# Deploy the generated/ directory with the code&lt;/span&gt;

&lt;span class="c"&gt;# On the server, during deploy hook&lt;/span&gt;
bin/magento setup:upgrade &lt;span class="nt"&gt;--keep-generated&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Only use &lt;code&gt;--keep-generated&lt;/code&gt; if you've already compiled DI for the exact code version being deployed. Mismatched generated code causes runtime errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Parallelize Where Possible
&lt;/h2&gt;

&lt;p&gt;Magento's &lt;code&gt;setup:upgrade&lt;/code&gt; runs modules sequentially. While you can't natively parallelize it, you can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run schema patches and DI compilation in parallel&lt;/strong&gt; — schema patches finish first, while DI compilation runs in a separate process&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-warm cache during upgrade&lt;/strong&gt; — use a background process to warm full page cache while upgrade runs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use maintenance mode strategically&lt;/strong&gt; — put the site in maintenance, run upgrade, then take it out. Don't leave the site live during upgrade.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/magento maintenance:enable
bin/magento setup:upgrade &lt;span class="nt"&gt;--keep-generated&lt;/span&gt; &amp;amp;
bin/magento setup:di:compile &amp;amp;
&lt;span class="nb"&gt;wait
&lt;/span&gt;bin/magento cache:clean
bin/magento maintenance:disable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 8: Database-Level Optimizations
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;setup:upgrade&lt;/code&gt; process is I/O-bound on MySQL. These database-side optimizations make a measurable difference:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Increase &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt;&lt;/strong&gt; — ensure the entire &lt;code&gt;setup_module&lt;/code&gt; and &lt;code&gt;setup_patches&lt;/code&gt; tables fit in memory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune &lt;code&gt;innodb_flush_log_at_trx_commit&lt;/code&gt;&lt;/strong&gt; — setting to &lt;code&gt;2&lt;/code&gt; during maintenance windows gives a 2-3x write speedup at the cost of ACID guarantees (safe for deployment windows)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop unused indexes before upgrade&lt;/strong&gt; — if a data patch recreates indexes, dropping them first and recreating after is faster than incremental updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Increase &lt;code&gt;innodb_io_capacity&lt;/code&gt;&lt;/strong&gt; — during maintenance, allow MySQL to use more I/O throughput for faster table operations
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Temporarily boost InnoDB performance during maintenance&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;innodb_flush_log_at_trx_commit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;innodb_io_capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;innodb_io_capacity_max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- After upgrade completes&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;innodb_flush_log_at_trx_commit&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="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;innodb_io_capacity&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="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;GLOBAL&lt;/span&gt; &lt;span class="n"&gt;innodb_io_capacity_max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember to reset these values after the upgrade completes. Running with relaxed durability settings permanently risks data loss on power failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: Audit Third-Party Modules
&lt;/h2&gt;

&lt;p&gt;Third-party modules often ship with heavy upgrade scripts. Common offenders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ERP integrations&lt;/strong&gt; — creating custom tables with initial data seed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payment gateways&lt;/strong&gt; — adding columns to &lt;code&gt;sales_order&lt;/code&gt; with default values on large tables&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search extensions&lt;/strong&gt; — rebuilding entire index tables during upgrade&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Audit the &lt;code&gt;Setup/Patch/&lt;/code&gt; directories of your installed modules. If you find slow patches, contact the vendor or patch them locally:&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;# Find all data patches across vendor modules&lt;/span&gt;
find vendor/ &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/Setup/Patch/Data/*.php'&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the worst offenders, you can often refactor the patch to use batch operations and submit a PR upstream. Many vendors are responsive to performance PRs, especially if you include benchmarks showing the before/after timings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 10: Track Upgrade Duration Over Time
&lt;/h2&gt;

&lt;p&gt;Once you've optimized, keep an eye on regressions. Log &lt;code&gt;setup:upgrade&lt;/code&gt; duration on every deployment:&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="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
bin/magento setup:upgrade &lt;span class="nt"&gt;--keep-generated&lt;/span&gt;
&lt;span class="nv"&gt;end&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;end &lt;span class="o"&gt;-&lt;/span&gt; start&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-Iseconds&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; setup_upgrade_duration=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;duration&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /var/log/magento-deployments.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Graph this in Grafana, Datadog, or your monitoring tool of choice. When a new deployment suddenly takes 2x longer, you'll know immediately which module introduced a slow patch. Set up alerts for when upgrade duration exceeds your baseline by more than 50%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;setup:upgrade&lt;/code&gt; doesn't have to be the bottleneck in your deployment pipeline. By profiling what's slow, optimizing data patches, migrating to declarative schema, skipping unnecessary work, and parallelizing where you can, large Magento 2 installs can go from 20-minute upgrades to under 5 minutes.&lt;/p&gt;

&lt;p&gt;The key takeaways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Profile first&lt;/strong&gt; — don't optimize blind, use verbose logging and MySQL slow query log&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch everything&lt;/strong&gt; — per-row operations in data patches are the #1 performance killer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;--keep-generated&lt;/code&gt;&lt;/strong&gt; — skip DI compilation during upgrade if it's done in CI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate to declarative schema&lt;/strong&gt; — it's faster, declarative, and auto-generates diffs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit third-party modules&lt;/strong&gt; — vendor patches are often the worst offenders&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track duration over time&lt;/strong&gt; — catch regressions before they become deployment-day emergencies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fast deployments mean more deployments. More deployments mean smaller changes, faster feedback, and less risk. Every minute you shave off &lt;code&gt;setup:upgrade&lt;/code&gt; pays dividends across your entire development workflow.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
      <category>deployment</category>
      <category>devops</category>
    </item>
    <item>
      <title>Magento 2 Email Performance Optimization: From SMTP Bottlenecks to Async Sending</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:03:57 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-email-performance-optimization-from-smtp-bottlenecks-to-async-sending-4bd1</link>
      <guid>https://dev.to/magevanta/magento-2-email-performance-optimization-from-smtp-bottlenecks-to-async-sending-4bd1</guid>
      <description>&lt;p&gt;Email is often the forgotten performance bottleneck in Magento 2. While stores obsess over page load times and database queries, the email subsystem quietly drags down checkout completion, order processing, and cron execution. A single slow SMTP handshake can add seconds to every order placement. Multiply that by hundreds of orders per hour and you have a real problem.&lt;/p&gt;

&lt;p&gt;In this post we'll diagnose the most common email performance issues and implement fixes that actually move the needle.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden Cost of Synchronous Email
&lt;/h2&gt;

&lt;p&gt;By default, Magento 2 sends emails synchronously during the request lifecycle. When a customer places an order, the system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Renders the email template (PHP + HTML compilation)&lt;/li&gt;
&lt;li&gt;Opens an SMTP connection&lt;/li&gt;
&lt;li&gt;Authenticates and transmits the message&lt;/li&gt;
&lt;li&gt;Waits for server acknowledgement&lt;/li&gt;
&lt;li&gt;Closes the connection&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 2-4 happen inside the same request that renders the order success page. If your SMTP server is 150ms away and takes 300ms to accept a message, every customer waits an extra 300-500ms after clicking "Place Order." During peak traffic this compounds into connection exhaustion and timeout errors.&lt;/p&gt;

&lt;p&gt;The worst part? These delays rarely show up in standard profiling because they're classified as "network I/O" rather than Magento code execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Enable Async Email Sending
&lt;/h2&gt;

&lt;p&gt;Magento 2.4+ includes built-in support for asynchronous email via RabbitMQ or MySQL message queues. This decouples email transmission from the frontend request.&lt;/p&gt;

&lt;p&gt;Enable it in &lt;code&gt;app/etc/env.php&lt;/code&gt;:&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;'system'&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;'default'&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;'sales_email'&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;'general'&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_sending'&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="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;Or via CLI for immediate effect:&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 config:set sales_email/general/async_sending 1
bin/magento cache:clean config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With async sending enabled, order confirmation emails are queued immediately and processed by a background consumer. The customer sees the success page instantly while the email ships separately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify the consumer is running:&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 queue:consumers:start sales.sendOrderEmails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For production, register this as a systemd service or Supervisord process. Without a running consumer, emails accumulate in the queue indefinitely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: SMTP Connection Pooling with External Providers
&lt;/h2&gt;

&lt;p&gt;Default Magento uses PHP's &lt;code&gt;mail()&lt;/code&gt; function or opens a fresh SMTP connection per email. Both are inefficient at scale.&lt;/p&gt;

&lt;p&gt;Switch to a transactional email service (Mailgun, SendGrid, AWS SES, Postmark) and configure connection reuse. Most support HTTP APIs that are faster than SMTP handshakes, but if you must use SMTP, enable persistent connections.&lt;/p&gt;

&lt;p&gt;For SendGrid via SMTP in &lt;code&gt;app/etc/env.php&lt;/code&gt;:&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;'system'&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;'default'&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;'system'&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;'smtp'&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;'host'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'smtp.sendgrid.net'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;587&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'auth'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'login'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'username'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'apikey'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'SG.your-api-key-here'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'ssl'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'tls'&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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better yet, use a Magento module that calls HTTP APIs directly. HTTP requests have lower overhead than SMTP connection negotiation and support keep-alive pooling natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: Template Compilation Cache
&lt;/h2&gt;

&lt;p&gt;Email templates in Magento 2 are PHP-based and compiled at runtime. The first time a template renders, Magento parses the &lt;code&gt;.html&lt;/code&gt; file, extracts directives (&lt;code&gt;{{var order.getIncrementId()}}&lt;/code&gt;), and generates executable PHP code.&lt;/p&gt;

&lt;p&gt;This compilation is cached, but the cache is filesystem-based and can be slow on NFS or shared storage. Ensure template compilation cache is stored in Redis:&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;'cache'&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;'frontend'&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;'default'&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;'backend'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'Magento\Framework\Cache\Backend\Redis'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'backend_options'&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;'server'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'127.0.0.1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'port'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'6379'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'database'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'password'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s1"&gt;'compress_data'&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;'compression_lib'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'gzip'&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="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Warm the email template cache after deployments by triggering a test email or visiting the template preview in Admin &amp;gt; Marketing &amp;gt; Email Templates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: Batch Processing for Bulk Operations
&lt;/h2&gt;

&lt;p&gt;Bulk actions like invoice generation, shipment creation, or newsletter blasts trigger dozens of emails in rapid succession. Each email in isolation is fast; together they overwhelm the mail subsystem.&lt;/p&gt;

&lt;p&gt;For newsletter sends or promotional campaigns, avoid Magento's native email loop. Instead, export recipient lists to your transactional email provider's batch API. SendGrid, Mailgun, and SES all support batch endpoints that accept thousands of recipients in a single request.&lt;/p&gt;

&lt;p&gt;For operational emails (invoices, shipments), implement a custom batch queue:&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BatchEmailSender&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$batchSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;queueEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$templateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$vars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$to&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="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'templateId'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'vars'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'to'&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="nb"&gt;count&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="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;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;batchSize&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="nb"&gt;flush&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&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="k"&gt;empty&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="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Send via provider's batch API or grouped SMTP transaction&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// Process&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="n"&gt;batch&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hook this into invoice and shipment mass-action controllers to reduce SMTP round-trips by 50x.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 5: Eliminate Unnecessary Emails
&lt;/h2&gt;

&lt;p&gt;Every module that hooks into &lt;code&gt;sales_order_place_after&lt;/code&gt; or &lt;code&gt;checkout_submit_all_after&lt;/code&gt; can trigger emails. Audit your observers:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"transportBuilder"&lt;/span&gt; app/code/ vendor/ &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.php"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; Test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each match, ask: does this email need to send immediately? Can it be deferred? Can it be suppressed entirely?&lt;/p&gt;

&lt;p&gt;Common offenders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Third-party review request modules&lt;/strong&gt; — sending 5 minutes post-purchase via cron. Move to daily batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inventory update notifications&lt;/strong&gt; — admins don't need real-time stock alerts. Digest them hourly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate order confirmations&lt;/strong&gt; — some payment modules send their own confirmation alongside Magento's. Disable one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix 6: Monitor the Email Queue
&lt;/h2&gt;

&lt;p&gt;You can't optimize what you don't measure. Add queue depth monitoring to your observability stack:&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;# Check RabbitMQ queue depth (if using RabbitMQ)&lt;/span&gt;
rabbitmqctl list_queues name messages | &lt;span class="nb"&gt;grep &lt;/span&gt;sales

&lt;span class="c"&gt;# Check MySQL queue (if using db queue)&lt;/span&gt;
mysql &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SELECT COUNT(*) FROM queue_message WHERE queue_id = (SELECT id FROM queue WHERE name = 'sales.sendOrderEmails')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alert when queue depth exceeds 100 messages or oldest message age exceeds 5 minutes. These thresholds catch consumer failures before customers complain about missing order emails.&lt;/p&gt;

&lt;p&gt;For SMTP-level monitoring, track these metrics per email provider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Delivery rate (should be &amp;gt;97%)&lt;/li&gt;
&lt;li&gt;Bounce rate (keep &amp;lt;2%)&lt;/li&gt;
&lt;li&gt;Average send latency (target &amp;lt;500ms from queue to acceptance)&lt;/li&gt;
&lt;li&gt;Daily volume vs. rate limits&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix 7: Tune Cron for Email-Heavy Jobs
&lt;/h2&gt;

&lt;p&gt;Magento's cron handles email consumers, reminder sends, and report generation. On email-heavy stores, dedicate a separate cron group:&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;!-- app/etc/cron_groups.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="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;group&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;schedule_generate_every&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/schedule_generate_every&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;schedule_ahead_for&amp;gt;&lt;/span&gt;4&lt;span class="nt"&gt;&amp;lt;/schedule_ahead_for&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;schedule_lifetime&amp;gt;&lt;/span&gt;2&lt;span class="nt"&gt;&amp;lt;/schedule_lifetime&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;history_cleanup_every&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/history_cleanup_every&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;history_success_lifetime&amp;gt;&lt;/span&gt;60&lt;span class="nt"&gt;&amp;lt;/history_success_lifetime&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;history_failure_lifetime&amp;gt;&lt;/span&gt;600&lt;span class="nt"&gt;&amp;lt;/history_failure_lifetime&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;use_separate_process&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/use_separate_process&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/group&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;Register email consumers under this group to prevent email backlog from blocking catalog price rule updates or indexer runs.&lt;/p&gt;

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

&lt;p&gt;Email performance is request-blocking, queue-dependent, and often invisible until it breaks. The fixes are straightforward but require changing default behaviors:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Synchronous SMTP in checkout&lt;/td&gt;
&lt;td&gt;Enable async sending&lt;/td&gt;
&lt;td&gt;-300-500ms per order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh SMTP connection per email&lt;/td&gt;
&lt;td&gt;Connection pooling / HTTP API&lt;/td&gt;
&lt;td&gt;-200ms per email&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Template compilation overhead&lt;/td&gt;
&lt;td&gt;Redis cache + pre-warm&lt;/td&gt;
&lt;td&gt;-50ms first render&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk email loops&lt;/td&gt;
&lt;td&gt;Batch processing&lt;/td&gt;
&lt;td&gt;10-50x throughput gain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unnecessary email triggers&lt;/td&gt;
&lt;td&gt;Audit &amp;amp; suppress observers&lt;/td&gt;
&lt;td&gt;Reduced queue pressure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consumer failures&lt;/td&gt;
&lt;td&gt;Queue depth monitoring&lt;/td&gt;
&lt;td&gt;Prevent silent email loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cron contention&lt;/td&gt;
&lt;td&gt;Dedicated email cron group&lt;/td&gt;
&lt;td&gt;Isolated email processing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Start with async sending — it's a single configuration change with immediate customer-facing impact. Then layer in SMTP optimization and monitoring. Your checkout completion rate will thank you.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Have you diagnosed email bottlenecks in your Magento store? Share your SMTP provider and latency numbers — the community needs more real-world benchmarks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
      <category>email</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>Magento 2 Product Relations Performance: Fixing Upsells, Crosssells &amp; Related Products at Scale</title>
      <dc:creator>Magevanta</dc:creator>
      <pubDate>Sun, 09 Aug 2026 09:03:58 +0000</pubDate>
      <link>https://dev.to/magevanta/magento-2-product-relations-performance-fixing-upsells-crosssells-related-products-at-scale-i16</link>
      <guid>https://dev.to/magevanta/magento-2-product-relations-performance-fixing-upsells-crosssells-related-products-at-scale-i16</guid>
      <description>&lt;p&gt;Every Magento 2 product detail page loads three types of product relations: &lt;strong&gt;upsells&lt;/strong&gt;, &lt;strong&gt;crosssells&lt;/strong&gt;, and &lt;strong&gt;related products&lt;/strong&gt;. On a small catalog, you barely notice them. On a large catalog with thousands of products and complex rule-based relations, they become one of the most expensive parts of your product page — and one of the hardest to debug.&lt;/p&gt;

&lt;p&gt;This guide breaks down how Magento 2 loads product relations, where the performance bottlenecks hide, and what you can do to keep your product pages fast even when every product has dozens of relations.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Magento 2 Loads Product Relations
&lt;/h2&gt;

&lt;p&gt;Magento 2 stores product relations in the &lt;code&gt;catalog_product_link&lt;/code&gt; table, along with the &lt;code&gt;catalog_product_link_type&lt;/code&gt; and &lt;code&gt;catalog_product_link_attribute&lt;/code&gt; tables. The three relation types — related (1), upsell (2), and crosssell (3) — are all stored in the same structure.&lt;/p&gt;

&lt;p&gt;When a customer opens a product detail page, Magento goes through this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Load the main product&lt;/strong&gt; via the product repository&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetch all links&lt;/strong&gt; for the product from &lt;code&gt;catalog_product_link&lt;/code&gt; where &lt;code&gt;product_id = &amp;lt;current_product&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load each linked product&lt;/strong&gt; as a full product entity (EAV load)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply pricing&lt;/strong&gt; — including tier prices, catalog price rules, and tax calculations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load product images&lt;/strong&gt; for each relation (gallery API)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply inventory status&lt;/strong&gt; — check stock for each related product&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render the relation blocks&lt;/strong&gt; (related products list, upsell block, crosssell block)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 3–6 are where things go wrong. Each linked product triggers a full EAV load, a price calculation, and an inventory check. If a product has 20 related products, that's 20 separate EAV queries, 20 price calculations, and 20 stock checks — most of which happen inside loops that Magento doesn't batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The N+1 Problem in Product Relations
&lt;/h2&gt;

&lt;p&gt;The core issue is an &lt;strong&gt;N+1 query pattern&lt;/strong&gt;. Magento loads the link collection (1 query), then loads each linked product individually (N queries). On a product with 15 related products, that's 16 queries minimum — and in practice, many more when you factor in EAV attributes, pricing, and inventory.&lt;/p&gt;

&lt;p&gt;Here's what the query log looks like on a typical PDP with 12 related products:&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;catalog_product_link&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1234&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 12 rows returned&lt;/span&gt;

&lt;span class="c1"&gt;-- For each linked product (×12):&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;catalog_product_entity&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;link_id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;catalog_product_entity_varchar&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;link_id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;catalog_product_entity_decimal&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;link_id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;catalog_product_index_price&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;link_id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cataloginventory_stock_status&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;link_id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's roughly &lt;strong&gt;60+ queries&lt;/strong&gt; just for the related products block. On a high-traffic store, this adds up to significant database pressure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosing the Problem
&lt;/h2&gt;

&lt;p&gt;Before optimizing, measure the actual impact. You need to know exactly how much time your product relations are adding to the PDP.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enable the Database Profiler
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In app/etc/env.php or a temporary diagnostic module&lt;/span&gt;
&lt;span class="s1"&gt;'profiler'&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;'class'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;\Magento\Framework\DB\Profiler&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="s1"&gt;'enabled'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&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;Or use the built-in profiler via &lt;code&gt;MAGE_PROFILER=1&lt;/code&gt; in your &lt;code&gt;.htaccess&lt;/code&gt; or Nginx config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="nc"&gt;SetEnv&lt;/span&gt; MAGE_PROFILER 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for query patterns that repeat for each linked product. Count the queries attributed to &lt;code&gt;catalog_product_link&lt;/code&gt; joins and subsequent EAV loads.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use Blackfire or New Relic
&lt;/h3&gt;

&lt;p&gt;Trace a product page with 10+ relations and look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;getLinkCollection()&lt;/code&gt; call tree&lt;/li&gt;
&lt;li&gt;Time spent in &lt;code&gt;\Magento\Catalog\Model\Product\Link::getLinkedProductCollection&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Total EAV load time for linked products&lt;/li&gt;
&lt;li&gt;Price calculation overhead (&lt;code&gt;getPriceInfo()&lt;/code&gt;, &lt;code&gt;getFinalPrice()&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your relations loading takes more than 200ms, you have a problem worth fixing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimization 1: Limit the Number of Loaded Relations
&lt;/h2&gt;

&lt;p&gt;The simplest fix is also the most effective: &lt;strong&gt;load fewer relations&lt;/strong&gt;. Magento lets you configure how many related/upsell/crosssell products to display, but the underlying collection still loads all of them before slicing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reduce the Link Collection Size
&lt;/h3&gt;

&lt;p&gt;In your product blocks, limit the collection early — before the EAV load happens:&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;// In a custom block that extends \Magento\Catalog\Block\Product\ProductList\Related&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;_prepareData&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="n"&gt;_itemCollection&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;_productFactory&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCollection&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;addAttributeToSelect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'small_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'short_description'&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;joinField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;'link_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'catalog_product_link'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'link_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'product_id=entity_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'{{table}}.linked_product_id='&lt;/span&gt; &lt;span class="mf"&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;getProduct&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;getId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'inner'&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;addLinkType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Catalog\Model\Product\Link&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;LINK_TYPE_RELATED&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;setPageSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// ← Critical: limit before EAV load&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="mi"&gt;1&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="n"&gt;_itemCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addStoreFilter&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="n"&gt;_itemCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addPriceData&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="n"&gt;_itemCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addTaxPercents&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="n"&gt;_itemCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;addUrlRewrite&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&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;The key is &lt;code&gt;setPageSize(6)&lt;/code&gt; — this limits the SQL query itself, so Magento only loads 6 products from the database instead of loading all 20 and displaying 6.&lt;/p&gt;

&lt;h3&gt;
  
  
  Audit Product Relations in Bulk
&lt;/h3&gt;

&lt;p&gt;If your merchandisers are adding 30+ related products per product, you have a data problem. Run this query to find the worst offenders:&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="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;link_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;relation_count&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;catalog_product_link&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;link_type&lt;/span&gt;
&lt;span class="k"&gt;HAVING&lt;/span&gt; &lt;span class="n"&gt;relation_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;relation_count&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any product with more than 10 relations of a single type is a candidate for cleanup. In most storefronts, 4–6 related products and 3–4 crosssells is plenty.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimization 2: Select Only the Attributes You Need
&lt;/h2&gt;

&lt;p&gt;By default, Magento loads the full EAV entity for each linked product — every attribute, including long descriptions, gallery data, and attributes that only matter on the PDP itself. For a relation block, you only need a handful of attributes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use &lt;code&gt;addAttributeToSelect&lt;/code&gt; Explicitly
&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;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;_itemCollection&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;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'small_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'short_description'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'url_key'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="c1"&gt;// Do NOT use addAttributeToSelect('*') — it loads every attribute&lt;/span&gt;
    &lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This alone can cut the EAV query count by 60–80% per linked product, especially on catalogs with many custom attributes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable the Flat Catalog for Relation Collections
&lt;/h3&gt;

&lt;p&gt;The flat catalog (&lt;code&gt;catalog_product_flat&lt;/code&gt;) is often recommended for performance, but it creates wide tables with every attribute. When Magento loads related products via the flat table, it selects all flat columns — even if you only need 5 attributes.&lt;/p&gt;

&lt;p&gt;For relation collections, bypass the flat catalog:&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;_itemCollection&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;setStoreId&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="n"&gt;_storeManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStore&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;getId&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;addAttributeToSelect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'small_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'short_description'&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;setFlag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'disable_flat_catalog'&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="c1"&gt;// Force EAV with limited attribute selection&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With limited &lt;code&gt;addAttributeToSelect&lt;/code&gt;, EAV is actually faster than flat for relation blocks because it only queries the specific attribute tables instead of joining the entire flat row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimization 3: Cache the Rendered Relation Blocks
&lt;/h2&gt;

&lt;p&gt;Product relations are ideal candidates for block-level caching because they change rarely. Magento's full page cache already handles PDP blocks, but if you're running without FPC (e.g., logged-in customers), relation blocks can bypass cache entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Add Cache Key Data to Relation Blocks
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In your custom relation block&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getCacheKeyInfo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s1"&gt;'PRODUCT_RELATIONS'&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;getProduct&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;getId&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="n"&gt;_storeManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStore&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;getId&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;getLinkType&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;getPageSize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;6&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;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getCacheLifetime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Cache for 24 hours&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Invalidate on Product Relation Changes
&lt;/h3&gt;

&lt;p&gt;Hook into the product save event to invalidate cached relation blocks:&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;!-- etc/frontend/events.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;event&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"catalog_product_save_after"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;observer&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"invalidate_product_relations_cache"&lt;/span&gt;
              &lt;span class="na"&gt;instance=&lt;/span&gt;&lt;span class="s"&gt;"Vendor\Module\Observer\InvalidateProductRelationsCache"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/event&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvalidateProductRelationsCache&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;\Magento\Framework\Event\ObserverInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Framework\App\CacheInterface&lt;/span&gt; &lt;span class="nv"&gt;$cache&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="n"&gt;cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;\Magento\Framework\Event\Observer&lt;/span&gt; &lt;span class="nv"&gt;$observer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$observer&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getEvent&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;getProduct&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="c1"&gt;// Invalidate this product's relation blocks&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;cache&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PRODUCT_RELATIONS_'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Optimization 4: Preload Product Data with a Single Query
&lt;/h2&gt;

&lt;p&gt;The most impactful optimization is replacing the N+1 pattern with a single batch query. Instead of loading each linked product individually, load them all in one go.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom Batch Loader
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BatchProductLoader&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;ProductRepositoryInterface&lt;/span&gt; &lt;span class="nv"&gt;$productRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$loadedProducts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;__construct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;ProductRepositoryInterface&lt;/span&gt; &lt;span class="nv"&gt;$productRepository&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="n"&gt;productRepository&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$productRepository&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;loadProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$productIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$storeId&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Filter out already-loaded products&lt;/span&gt;
        &lt;span class="nv"&gt;$neededIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;array_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productIds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;array_keys&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="n"&gt;loadedProducts&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$neededIds&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_intersect_key&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="n"&gt;loadedProducts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;array_flip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productIds&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;=&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;productRepository&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCollection&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;addAttributeToSelect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'small_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'short_description'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'url_key'&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;addStoreFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$storeId&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;addUrlRewrite&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;addFieldToFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'entity_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'in'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$neededIds&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;addPriceData&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;addTierPriceData&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;addTaxPercents&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="n"&gt;loadedProducts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getId&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&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="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;array_intersect_key&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="n"&gt;loadedProducts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;array_flip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productIds&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;By injecting this loader and using it in your relation blocks, you replace N individual EAV loads with &lt;strong&gt;one collection query&lt;/strong&gt; that fetches all linked products at once. The price, tax, and URL rewrite data are also loaded in batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimization 5: Replace Manual Relations with Rule-Based Loading
&lt;/h2&gt;

&lt;p&gt;Manual product relations (where a merchandiser manually links products) don't scale. When every product needs 6 related products and you have 10,000 products, that's 60,000 manual links to maintain.&lt;/p&gt;

&lt;p&gt;Rule-based relations generate the related products block dynamically based on shared attributes:&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;_itemCollection&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;_productFactory&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="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getCollection&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;addAttributeToSelect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'small_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'short_description'&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;addAttributeToFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'attribute_set_id'&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;getProduct&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;getAttributeSetId&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;addAttributeToFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'entity_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'neq'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;getProduct&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;getId&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;setPageSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&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;setCurPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;addStoreFilter&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;addPriceData&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;addUrlRewrite&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This eliminates the &lt;code&gt;catalog_product_link&lt;/code&gt; table entirely and generates relations from shared attributes (same category, same brand, same attribute set). The collection query is a single indexed lookup — no N+1 pattern, no manual data entry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Use the Sales Rule Approach
&lt;/h3&gt;

&lt;p&gt;For even better results, use &lt;strong&gt;sales data&lt;/strong&gt; to generate relations. Products that were frequently bought together make excellent crosssell recommendations:&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="n"&gt;soi2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;frequency&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sales_order_item&lt;/span&gt; &lt;span class="n"&gt;soi1&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sales_order_item&lt;/span&gt; &lt;span class="n"&gt;soi2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;soi1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;soi2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;soi1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;soi2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;soi1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;soi2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'simple'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;soi2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;frequency&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cache the result per product and regenerate nightly via a cron job. This approach eliminates the link table entirely and shows products that customers actually buy together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimization 6: Disable Unused Relation Types
&lt;/h2&gt;

&lt;p&gt;Not every store uses all three relation types. If you don't use upsells on your storefront, disable the block entirely — don't let the system load products that will never be displayed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Disable Blocks via Layout XML
&lt;/h3&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;!-- In your theme's layout override: Magento_Catalog/layout/catalog_product_view.xml --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;page&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="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;body&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- Remove upsell block entirely --&amp;gt;&lt;/span&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;"product.info.upsell"&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="c"&gt;&amp;lt;!-- Or remove crosssell --&amp;gt;&lt;/span&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;"checkout.cart.crosssell"&lt;/span&gt; &lt;span class="na"&gt;remove=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/page&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prevents the block from being created, the collection from being initialized, and the queries from running. It's a free win if the block goes unused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimization 7: Lazy-Load Relations via AJAX
&lt;/h2&gt;

&lt;p&gt;For product pages where relations are nice-to-have but not critical, load them asynchronously after the main page renders. This keeps the initial TTFB and LCP fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create an AJAX Endpoint
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In a custom controller: Vendor\Module\Controller\Product\Related&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$productId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;int&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;getRequest&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;getParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$product&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;productRepository&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$productId&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;=&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getRelatedProductCollection&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;addAttributeToSelect&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'small_image'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'short_description'&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;setPageSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&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;addStoreFilter&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;addPriceData&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;addUrlRewrite&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nv"&gt;$data&lt;/span&gt; &lt;span class="o"&gt;=&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;$related&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$data&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s1"&gt;'name'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$related&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'price'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$related&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getFinalPrice&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'image'&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;imageHelper&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$related&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'product_page_image_small'&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;getUrl&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="s1"&gt;'url'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$related&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getProductUrl&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;return&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;jsonResponse&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'items'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$data&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;h3&gt;
  
  
  Load via JavaScript on the Frontend
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;define&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jquery&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;$&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ready&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&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;[data-product-id]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;product-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;$&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;/rest/V1/custom/related/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;productId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;div class="related-product"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
                        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;a href="&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
                        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;img src="&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;" alt="&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
                        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;span&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
                        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;span class="price"&amp;gt;€&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
                        &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;/a&amp;gt;&amp;lt;/div&amp;gt;&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nf"&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;.related-products-container&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;html&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&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="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;This shifts the relation loading off the critical rendering path entirely. The main product page renders fast, and relations load progressively — improving Core Web Vitals while keeping the upsell/crosssell functionality intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optimization 8: Inventory and Price Loading Optimization
&lt;/h2&gt;

&lt;p&gt;Two expensive operations happen for each linked product: &lt;strong&gt;stock status check&lt;/strong&gt; and &lt;strong&gt;price calculation&lt;/strong&gt;. Both involve database queries and computation that multiply across all relations.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch Stock Status
&lt;/h3&gt;

&lt;p&gt;Magento's stock status indexer already writes to &lt;code&gt;cataloginventory_stock_status&lt;/code&gt; — make sure linked products are reading from the index, not the live stock tables. With MSI installed, ensure the stock status indexer is up to date:&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:reindex inventory
bin/magento indexer:set-status realtime cataloginventory_stock_status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For relation blocks, use the indexed status directly:&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;_itemCollection&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;joinField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'is_in_stock'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'cataloginventory_stock_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'stock_status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'product_id=entity_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'{{table}}.stock_id=1 AND {{table}}.website_id='&lt;/span&gt; &lt;span class="mf"&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;_storeManager&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getStore&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;getWebsiteId&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="s1"&gt;'inner'&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;addFieldToFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'is_in_stock'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'eq'&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;p&gt;This filters out out-of-stock products at the SQL level, avoiding loading products that would only be hidden later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cache Price Calculations
&lt;/h3&gt;

&lt;p&gt;Price calculation for linked products involves catalog price rules, tier prices, and tax. Cache the final price per product per customer group:&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;// Use the price index table directly instead of calculating on-the-fly&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;_itemCollection&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;joinField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'final_price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'catalog_product_index_price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'final_price'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'entity_id=entity_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'{{table}}.customer_group_id=0 AND {{table}}.website_id='&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$websiteId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'inner'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading from the price index table is a single join — far cheaper than running the full price calculation pipeline for each product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist: Product Relations Performance Audit
&lt;/h2&gt;

&lt;p&gt;Run through this checklist to audit your store:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Count relations per product&lt;/strong&gt; — any product with more than 8 relations of one type needs cleanup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Profile a PDP with 10+ relations&lt;/strong&gt; — measure query count and total time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limit &lt;code&gt;addAttributeToSelect&lt;/code&gt;&lt;/strong&gt; to only needed attributes (name, price, image, url_key, short_description)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;setPageSize()&lt;/code&gt;&lt;/strong&gt; on relation collections to limit at SQL level&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove unused relation blocks&lt;/strong&gt; via layout XML overrides&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add block-level caching&lt;/strong&gt; with proper cache key and invalidation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider AJAX lazy-loading&lt;/strong&gt; for non-critical relations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use indexed price and stock data&lt;/strong&gt; instead of live calculations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider rule-based relations&lt;/strong&gt; to eliminate manual link maintenance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reindex regularly&lt;/strong&gt; — stale price and stock indexes force fallback to live calculation&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Product relations are one of those features that feels free — you just link some products in the admin and they appear on the storefront. But the underlying query pattern is fundamentally inefficient: for each relation, Magento loads a full product entity, calculates pricing, checks stock, and renders a block. Multiply that by 10–20 products per page, and you've added hundreds of milliseconds to every product detail page.&lt;/p&gt;

&lt;p&gt;The optimizations in this guide range from quick wins (limiting page size, reducing attribute selection) to architectural changes (batch loading, AJAX deferred loading, rule-based relations). Start with the quick wins on your worst-performing product pages, measure the improvement, and work toward the architectural changes as your catalog grows.&lt;/p&gt;

&lt;p&gt;Remember: a product page that loads in 300ms with 6 well-chosen related products will convert better than a page that loads in 1.2s with 20 related products that the customer scrolls past anyway. Fewer, faster, better.&lt;/p&gt;

</description>
      <category>magento</category>
      <category>performance</category>
      <category>ecommerce</category>
      <category>php</category>
    </item>
  </channel>
</rss>
