•
15 February 2026
•
8 mins
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
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’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.
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.
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()
);
...
}
After optimizing, checkout latency dropped from ~4s to ~1.2 s under the same load.
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.
Like it? Share it!