Logo run_as_root - Magento B2B Agency Würzburg
PERFORMANCE

Observer Recursion in Magento 2: The Hidden Cost of sales_order_save_after

Three observers each calling $order->save() on sales_order_save_after cascade into 4+ saves per checkout. Detection, three refactor patterns, when each fits.


On this page

    The customer clicks "place order" and waits. Five seconds. Seven seconds. Ten. The Stripe token went through in 600 milliseconds, cleared on Stripe's side, logged in the dashboard. The customer is staring at a spinner while Magento is busy talking to itself.

    You open Blackfire. Somewhere in the call graph, Magento\Sales\Model\Order::save() appears once, twice, four times, nested inside itself. Each call re-runs every observer on sales_order_save_after. Each observer saves the order again. And each of those saves re-runs every observer.

    We find this on most Magento 2 stores running custom checkout logic. Nobody wrote the loop on purpose. Three developers, over three years, each added a perfectly reasonable observer. When all three are loaded in the same codebase, the observers save the order in each other's callbacks, and the cascade writes itself.

    TL;DR

    • sales_order_save_after fires on every $order->save(). If your observer calls $order->save() (or $this->orderRepository->save($order)), you have just re-dispatched the event to yourself.
    • A codebase with three observers on this event, each saving the order, can cascade to four or more full order saves per checkout. Each save carries the full observer chain, not just the one you care about.
    • The right event depends on what you want to do. For work that runs once per placed order, use checkout_submit_all_after or sales_order_place_after, not sales_order_save_after.
    • For anything that can be deferred, move it to a queued job. Don't make the customer wait for your Freshdesk webhook.
    • $order->setDataChanges(false) before the final save is the tactical patch when you can't refactor the upstream module, but it only short-circuits the outer save, not the full cascade.

    How the cascade forms

    Magento's event system is synchronous. When your code calls $order->save(), the AbstractModel::save() pipeline runs through its _beforeSave, writes the row, then dispatches sales_order_save_after. Every observer subscribed to that event runs inline before save() returns.

    If one of those observers calls $order->save() as part of its work, the dispatch happens again. Every observer runs again. If one of those observers also calls $order->save(), the dispatch happens a third time. There is no built-in recursion guard. AbstractModel::save() will happily nest as deep as PHP's stack lets it.

    The reason this keeps happening on Magento 2 stores is that writing "save the order when it's saved" feels like the obvious place for post-save bookkeeping. A developer hooks sales_order_save_after, writes something to an order attribute, and calls save() to persist it. By itself, one observer doing that is fine. The loop is invisible. The performance cost is one extra save, which is unpleasant but not catastrophic.

    The catastrophe appears when a second team adds a second observer, and then a third team adds a third. None of the three know about the others. Nobody runs a Blackfire trace on checkout. And one day someone asks why production checkout has a ten-second TTFB and everyone's first instinct is to blame the payment gateway.

    What this looks like in your codebase

    The pattern, constructed to keep things generic: three observers from three different modules, each reasonable on its own.

    1// app/code/Acme/Checkout/Observer/SplitOrderData.php
    2final class SplitOrderData implements ObserverInterface
    3{
    4 public function execute(Observer $observer): void
    5 {
    6 $order = $observer->getEvent()->getOrder();
    7 $order->setData('acme_split_segments', $this->splitter->forOrder($order));
    8 $order->save(); // re-fires sales_order_save_after
    9 }
    10}
    1// app/code/Acme/Notifications/Observer/SyncExternalCustomer.php
    2final class SyncExternalCustomer implements ObserverInterface
    3{
    4 public function execute(Observer $observer): void
    5 {
    6 $order = $observer->getEvent()->getOrder();
    7 $this->notifications->push($order); // HTTP to Freshdesk
    8 $order->setData('acme_notified_at', time());
    9 $this->orderRepository->save($order); // re-fires sales_order_save_after
    10 }
    11}
    1// app/code/Acme/Warehouse/Observer/CheckOrderItems.php
    2final class CheckOrderItems implements ObserverInterface
    3{
    4 public function execute(Observer $observer): void
    5 {
    6 $order = $observer->getEvent()->getOrder();
    7 foreach ($order->getAllVisibleItems() as $item) {
    8 $item->getProduct()->getStockItem(); // N+1 per child
    9 }
    10 $order->setData('acme_items_checked', 1);
    11 $order->save(); // re-fires sales_order_save_after
    12 }
    13}

    The first save of the order kicks off all three observers. Each saves. Each save re-runs all three. Depending on the data flags and short-circuits in each observer's logic, you land somewhere between four and a dozen full Order::save() cycles for a single placed order. And if any of those observers makes a synchronous HTTP call (the Freshdesk push above, for instance), each cascade level pays the network round-trip again.

    ⚠️
    The anti-pattern is not "observer plus save". It's "observer on sales_order_save_after plus save". Observers on sales_order_place_after or checkout_submit_all_after can call save() without creating a loop, because those events fire once per order placement, not once per model save. Always check which event you're hooked to before you blame the observer itself.

    Detecting the loop in under five minutes

    You don't need a full performance audit to find this. Two options.

    Option A: Blackfire call graph. Run a production Blackfire trace against a placed order (stage environment with a real payment gateway in test mode is fine). Search the call tree for Magento\Sales\Model\Order::save. Count the inclusive invocations. Anything above two is probably a cascade; above five, definitely. The call graph also shows you the observer classes sitting inside each save, which hands you the refactor target.

    Option B: a ten-line counter. If you don't have Blackfire on the environment, drop this plugin into a dev module and clear DI cache:

    1// app/code/Acme/Debug/Plugin/OrderSaveCounter.php
    2final class OrderSaveCounter
    3{
    4 public function beforeSave(\Magento\Sales\Model\Order $order): void
    5 {
    6 error_log(sprintf(
    7 '[order-save-counter] order=%s backtrace=%s',
    8 (string) $order->getIncrementId(),
    9 (new \Exception())->getTraceAsString()
    10 ));
    11 }
    12}

    Place one order. Grep var/log/system.log (or wherever your error_log lands) for order-save-counter. One hit per line = one Order::save() call. Four or more hits for one increment_id and you have your cascade. The backtrace on each line tells you exactly which observer triggered the next level.

    Remove the plugin after you've got your numbers. Running a stacktrace dump in production observers is not a good idea for anything other than targeted debugging.

    Refactor 1: move post-order work to a queued job

    Most of what your observers are doing doesn't need to block the customer. Freshdesk syncs, order-split computations, inventory notifications, cross-system audit logs, all of it can run after the checkout response has already gone back to the browser. If the business doesn't need the sync to complete before the customer sees the thank-you page, you shouldn't be doing it there.

    The shape:

    1final class SyncExternalCustomer implements ObserverInterface
    2{
    3 public function __construct(
    4 private readonly PublisherInterface $publisher,
    5 ) {}
    6 
    7 public function execute(Observer $observer): void
    8 {
    9 $order = $observer->getEvent()->getOrder();
    10 $this->publisher->publish(
    11 'acme.order.sync_external_customer',
    12 (int) $order->getId(),
    13 );
    14 }
    15}

    The handler on the queue side loads the order fresh, does the HTTP call, updates the attribute, saves once. No cascade. The customer gets the thank-you page in the time it took to serialise one message to RabbitMQ, which is measured in single-digit milliseconds on a healthy cluster.

    The right event to hook on is sales_order_place_after. That one fires once per placed order, not once per model save. You won't be in a loop, and you won't need to worry about which of the four cascading saves your observer happens to be on.

    Refactor 2: switch to a single-fire event

    For work that genuinely has to be synchronous, the fix is usually the event, not the logic. Magento's checkout flow fires checkout_submit_all_after once, after the quote and order are both committed, inside the original transaction. It's the correct event for any "at the moment of order placement" work that writes to the order and needs to be atomic with the checkout.

    1<event name="checkout_submit_all_after">
    2 <observer name="acme_split_order_data" instance="Acme\Checkout\Observer\SplitOrderData"/>
    3</event>

    Same observer class. Different event. The observer now runs exactly once. Calling $order->save() inside it re-fires sales_order_save_after, which still runs any observers hooked to that event, but the observer you just refactored no longer participates in the cascade because it's not hooked to that event anymore.

    The trap to avoid: sales_order_save_commit_after. That event fires on every save too. Moving from sales_order_save_after to sales_order_save_commit_after reshuffles the deck chairs but doesn't fix the cascade. The only fix is leaving the save-after family of events entirely.

    Refactor 3: the setDataChanges(false) tactical patch

    Sometimes the offending observer is in vendor code you can't refactor, shipped by a Marketplace module with a support agreement that forbids patching. In those cases, the last-ditch tactical patch is to convince the outer save that it has nothing to write, so the dispatch short-circuits.

    1$order->setDataChanges(false);
    2$order->save();

    When data_changes is false and the model's dirty-attribute tracking agrees, the save() pipeline skips the actual UPDATE and, crucially, skips dispatching the _save_after event entirely. The observer chain doesn't run.

    ⚠️
    This works on the outer save only. If an inner observer has already called $order->save() without setting data_changes=false first, you're in the cascade and the flag can't retroactively get you out. Use this pattern only as a wrapper at the top of the chain, and only when the "right fix" (refactor 1 or 2) isn't available to you this sprint.

    The other weakness of this pattern is it's a behaviour-change patch without an obvious name. Six months later, a different developer looks at the line, thinks it's dead code, and removes it. The cascade comes back. If you ship this, ship it with a comment explaining the why and a link to this article, the Blackfire baseline, or the internal ticket.

    Which refactor to pick when

    Scenario Refactor
    Work can be async (email, webhook, CRM sync, audit log) Refactor 1: queued job on sales_order_place_after
    Work must run inside the checkout transaction (order-attribute mutation, line-item rewrite) Refactor 2: move observer to checkout_submit_all_after
    Offending observer is vendor code you cannot patch Refactor 3: setDataChanges(false) wrap, with a comment
    You own all the code and have time for the right fix Refactor 2 for the synchronous bits, refactor 1 for everything else

    Rule of thumb: if the work belongs on the placed order rather than on every order save, the event is wrong. Fix the event, not the cascade.

    The observer recursion checklist

    1. Trace an order placement

      Blackfire or Tideways a staged checkout on the path you're investigating. Note the inclusive invocation count for Magento\Sales\Model\Order::save. Anything above two is suspicious.

    2. List every observer on the save family of events

      grep -rn --include='*.xml' -E 'sales_order_save_after|sales_order_save_commit_after' app/code/. Every result is a candidate for audit. Read each one; any observer that calls $order->save() or $this->orderRepository->save($order) is in the cascade.

    3. Classify each observer by what it does

      Async-eligible (webhook, email, audit log) vs synchronous (order attribute mutation that must be atomic with placement). The classification drives the refactor choice.

    4. Move async-eligible work to a queued job

      Hook the trigger on sales_order_place_after, publish a message, process in a handler that loads the order fresh and saves once. RabbitMQ or MySQL queue, your choice.

    5. Move synchronous work off sales_order_save_after

      checkout_submit_all_after for checkout-time work, sales_order_place_after for place-time work. Both fire once per order. Neither creates a loop.

    6. Apply setDataChanges(false) only where you can't refactor

      Vendor code with no patching path, tight deadline, low confidence in the fix window. Comment the why. Don't make it the default strategy.

    7. Re-trace after each refactor

      Same Blackfire trace on the same path. Order::save inclusive count should drop to one. Placed-order TTFB should drop by whatever the cascade was costing, which on busy stores is commonly in the two-to-five second range.

    8. Add a CI guard

      A static analysis rule or a grep in your CI config that fails the build if anyone adds a new observer on sales_order_save_after that calls save(). Cheap insurance against the next developer rebuilding the cascade.

    Observer cascades are a symptom, not the disease

    We do Magento 2 code audits that hand you the exact observer list, the cascade depth, and a prioritised refactor plan. Custom-development follow-up if you want us to ship the fix. Straight to code.

    Talk to us about the refactor