Saving seconds in checkout - Auditing a popular tax extension in Magento

15 February 2026

8 mins

The problem

During a recent audit of our Magento storefront we noticed checkout performance was degrading when tax calculations were performed. Using NewRelic APM we traced the slowdown to a classic query‑in‑a‑loop anti‑pattern.

Documented in this Github Issue

How it manifests

The TaxJar extension iterates over the cart items and, for each item, calls the Magento product repository to fetch the product object.

The product repository, unfortunately, fetches all attributes, including unnecessary ones for the purposes of calculating taxes.

Unfortunately, this is slightly non-obvious, as mapItem() (the method in which the repository call is made) is called for each item in the cart.

The mapItem() caller - from the Magento core sourcecode.

 foreach ($items as $item) {
        if ($item->getParentItem()) {
            continue;
        }
        if ($item->getHasChildren() && $item->isChildrenCalculated()) {
            $parentItemDataObject = $this->mapItem(
                $itemDataObjectFactory,
                $item,
                $priceIncludesTax,
                $useBaseCurrency
            );
            $itemDataObjects[] = [$parentItemDataObject];
            foreach ($item->getChildren() as $child) {
                $childItemDataObject = $this->mapItem(
                    $itemDataObjectFactory,
                    $child,
                    $priceIncludesTax,
                    $useBaseCurrency,
                    $parentItemDataObject->getCode()
                );
                $itemDataObjects[] = [$childItemDataObject];
                $extraTaxableItems = $this->mapItemExtraTaxables(
                    $itemDataObjectFactory,
                    $item,
                    $priceIncludesTax,
                    $useBaseCurrency
                );
                $itemDataObjects[] = $extraTaxableItems;
            }
        } else {
            $itemDataObject = $this->mapItem($itemDataObjectFactory, $item, $priceIncludesTax, $useBaseCurrency);
            $itemDataObjects[] = [$itemDataObject];
            $extraTaxableItems = $this->mapItemExtraTaxables(
                $itemDataObjectFactory,
                $item,
                $priceIncludesTax,
                $useBaseCurrency
            );
            $itemDataObjects[] = $extraTaxableItems;
        }
    }

The implementation of mapItem() from The taxjar github repo

    public function mapItem(
        \Magento\Tax\Api\Data\QuoteDetailsItemInterfaceFactory $itemDataObjectFactory,
        \Magento\Quote\Model\Quote\Item\AbstractItem $item,
        $priceIncludesTax,
        $useBaseCurrency,
        $parentCode = null
    ) {
        $itemDataObject = parent::mapItem(
            $itemDataObjectFactory,
            $item,
            $priceIncludesTax,
            $useBaseCurrency,
            $parentCode
        );
        ... 
        try {
            $product = $this->productRepository->getById($item->getProductId(), false, $item->getStoreId());

            // Configurable products should use the PTC of the child (when available)
            if ($product->getTypeId() == 'configurable') {
                $children = $item->getChildren();

                if (is_array($children) && isset($children[0])) {
                    $product = $this->productRepository->getById(
                        $children[0]->getProductId(),
                        false,
                        $item->getStoreId()
                    );
                }
            }

            $extensionAttributes->setTjPtc($product->getTjPtc());
        } catch (\Magento\Framework\Exception\NoSuchEntityException $e) {
            $msg = $e->getMessage() . "\nline item" . $itemDataObject->getCode() . ' for ' . $item->getRowTotal();
            $this->logger->log($msg);
        }

        $extensionAttributes->setJurisdictionTaxRates($jurisdictionRates);

        $itemDataObject->setExtensionAttributes($extensionAttributes);

        return $itemDataObject;
    }

Each getById() results in a separate database query. On a cart with 20+ items this translates into 20 queries, and each query incurs a round‑trip to the database, a full EAV join, and caching logic. In production, under load, this can push checkout latency from ~1 s to >5 s.

NewRelic evidence

NewRelic’s transaction tracer highlighted the ` Magento\Catalog\Repository\ProductRepository::getById` method as the slowest call. The transaction map showed a linear increase in response time with the number of cart items – classic evidence of a loop‑based N+1 query.

Potential Fixes

Batch load products using a Collection

Instead of using the repository use a collection and fetch only the requirements needed - In this case tj_ptc.

This requires overriding mapItems() (Taxjar already extends \Magento\Tax\Model\Sales\Total\Quote\Tax) to create a hashtable of items to lookup the product.

        foreach ($items as $item)
        {
            $productIds[] = $item->getProductId();
            if ($item->getHasChildren() && $item->getProductType() == 'configurable') {
                foreach($item->getChildren() as $child) {
                    $productIds[] = $child->getProductId();
                }
            }
        } // Roll up the products to aggregate the query.
        $productCollection = $this->productCollectionFactory->create()
            ->addAttributeToSelect(['tj_ptc'])
            ->addFieldToFilter('entity_id', ['in' => $productIds])
            ->load();
        foreach($productCollection as $product) {
            $this->productMap[$product->getId()] = $product;
        }

Which mapItem() can then use later:

        try {
            $product = $this->productMap[$item->getProductId()];

            // Configurable products should use the PTC of the child (when available)
            if ($product->getTypeId() == 'configurable') {
                $children = $item->getChildren();

                if (is_array($children) && isset($children[0])) {
                    $product = $this->productMap[
                        $children[0]->getProductId()
                    ];
                }
            }

            $extensionAttributes->setTjPtc($product->getTjPtc());
        } catch (\Magento\Framework\Exception\NoSuchEntityException $e) {
            $msg = $e->getMessage() . "\nline item" . $itemDataObject->getCode() . ' for ' . $item->getRowTotal();
            $this->logger->log($msg);
        }

Note - I don’t believe this is ideal design pattern-wise as mapItem() would now depend on being called by mapItems().

Although, design-wise the caller (mapItems) is the only method that has access to the list of items in aggregate. So this avoids more significant refactoring and intrusive rewriting of vendor code. This isn’t the ideal solution in this case, but it’s included as a thought exercise because aggregating these sorts of operations this way, and building these hashtables for quick lookup is often the solution to Magento 2 performance issues.

Use catalog_attributes.xml

This is the better solution to this problem. Magento has a feature called etc/catalog_attributes.xml. This allows you to inject additional attributes into the products associated with catalog item collections.

<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Catalog:etc/catalog_attributes.xsd">
    <group name="quote_item">
        <attribute name="tj_ptc">
    </group>
</config>

Which works like this, see: Quote Item Collection on Github

 protected function _assignProducts(): self
 {
        \Magento\Framework\Profiler::start('QUOTE:' . __METHOD__, ['group' => 'QUOTE', 'method' => __METHOD__]);
        $productCollection = $this->_productCollectionFactory->create()->setStoreId(
            $this->getStoreId()
        )->addIdFilter(
            $this->_productIds
        )->addAttributeToSelect(
            $this->_quoteConfig->getProductAttributes()
        );
        ...
 }

Performance gains

After optimizing, checkout latency dropped from ~4s to ~1.2 s under the same load.

Takeaways

Just because an extension is popular does not mean it can’t be impacting your performance. Doing your due diligence with your observability tools can pay dividends for your store. Both in terms of increasing conversion rate, and by reducing the resources needed to host your store.

In this case this issue is particularly egregious because it creates barriers to conversion at the step in the funnel that is the customer’s highest intent step, visiting checkout.