Redirects are supposed to be cheap. A visitor asks for an old URL, the store answers with a 301, and the old link keeps its value. On Magento, the Amasty SEO Toolkit is a popular way to manage those redirects from the admin, including the ones created after a migration, an SEO clean-up or a catalogue restructure.
On one of the Adobe Commerce stores we support, the redirect table had grown to about 12,700 rows, with more than 12,000 of them active in most of its store views. Cached pages never touched the redirect code, so the cost was hiding in the traffic that matters most: checkout, customer accounts, cart actions and the AJAX calls that load private content on every page. This article explains where the time went, how we fixed it with a small patch, and what to watch for if you use the same module. The patch is available for download at the end.

Where the Redirect Check Sits
Magento decides which code handles a request by asking a chain of routers in order. Each router either claims the request or passes it on. Amasty SEO Toolkit adds two routers to that chain:
- sortOrder 20: core URL rewrites (products, categories, CMS pages)
- sortOrder 21: Amasty redirect router for redirects that apply to any page
- sortOrder 30: the standard router (checkout, customer, search, AJAX endpoints)
- sortOrder 60: CMS router
- sortOrder 61: Amasty redirect router for redirects that apply only to pages that do not exist
- sortOrder 100: default router, which ends in a 404
The first Amasty router sits directly after URL rewrites. Every request that is not a product, category or CMS URL passes through it: the checkout, the customer account, add-to-cart and search, and the customer/section/load calls that Luma and Hyvä make to fill in the mini-cart and customer name. A request that ends in a 404 passes through both Amasty routers.

What Each Check Cost
In the version we run, 1.3.0 of amasty/module-seo-toolkit-lite, each router call asked the database for every active redirect of the current store view, sorted by priority. It then walked through the whole list in PHP, comparing each request_path with the current URL, until it found a match. For most requests there is no match, so the loop ran to the end.
On our store, that meant 12,392 rows and about 1.1 MB of URL paths per call, turned into 12,392 PHP objects, then compared one by one. A 404 did the whole thing twice.
We measured the query alone against a staging copy of the database, roughly, through an SSH tunnel where any trivial query took 40 to 50 ms. The unfiltered redirect query took between 160 and 340 ms. The filtered query from our patch stayed at the baseline. That gap is the database time only, before PHP builds and checks twelve thousand objects, on requests that full page cache cannot help with.
The cost grows with every redirect you add. Stores collect redirects over time: after a platform migration, after merging product variants into parent products, after each round of fixing 404s found in Search Console. A module that is fast with 200 redirects can be slow with 12,000, and nothing in the admin warns you when you cross that line.
The Fix: Let the Database Do the Matching
The principle is simple. Instead of loading every redirect and testing each one in PHP, ask the database only for the redirects that can match the current path. Our patch changes three things:
- A path filter in SQL. The collection gets a new
addRequestPathFilter()method. It keeps only rows whoserequest_path, read as a pattern with*turned into[[:alnum:]]*, matches the beginning of the requested path. - A cheap prefilter. Before the pattern match, a
LIKE 'first-segment%'condition narrows the candidates to rows that start with the first segment of the URL. A new index onrequest_pathlets the database use that condition instead of scanning the table. - The original check stays. The module still runs its own PHP comparison on the handful of rows that come back, so the behaviour for matching redirects does not change.
For a typical request, the query now returns zero rows. For a request that does have a redirect, it returns one or two. The work no longer depends on how many redirects the store has.
Know the Limits Before You Apply It
Moving matching into SQL changes some edge cases. We found these while bulk-loading redirects later, and they are worth knowing before you apply the patch:
- The request path is used as a regular expression. A path containing an unbalanced
)or other regex syntax makes the query fail. Bots generate URLs like that, so do not import them blindly. Put paths with special characters into core URL rewrites instead, which match exactly. - Wildcards must start with the full first segment. The prefilter uses the first segment of the requested URL. A rule like
brands/*works. A rule likebrand*no longer matchesbrands/xyz. - Matching uses the raw path. The router compares against the path exactly as the browser sends it, percent-encoding included and query string excluded. Store request paths the same way.
- Run
setup:upgrade. The index is declared indb_schema.xml, so it is created on the next schema update.
Keeping the table small helps too. A second patch of ours gives redirects created in the admin a 90-day expiry, so temporary redirects expire instead of piling up for years.
How We Found It, and How to Check Your Own Store
Full page cache hides this class of problem. Product and category pages are served from cache and never reach the router, so the storefront feels fast. The slow path is the uncached one, which is exactly where conversion happens.
A few habits catch it early:
- Profile uncached requests, not just page loads. Transactions for
customer/section/load, checkout and cart actions in your APM tool are a good place to start. - Count the rows behind every admin-managed list that a router or observer reads on each request: redirects, rewrites, rules. Growth is gradual, so it is easy to miss.
- When a third-party module is slow, read its router and observers. In this case the problem and the fix both fit in a few dozen lines.
- Patch the vendor package with a Composer patch rather than overriding the class. The fix stays visible, applies on every build, and is easy to remove when the vendor ships its own.
Redirect performance is one small part of e-commerce performance optimization, but it is typical of how slowdowns build up in a mature Magento store: one reasonable module, one table that keeps growing, and a cost that only shows in the requests nobody profiles. Keeping track of that is part of our Magento support work.
Download the Patch
The patch targets amasty/module-seo-toolkit-lite 1.3.0. It is provided as-is: read it, test it on staging, and check that it still applies to the version you run. Apply it with cweagans/composer-patches or vaimo/composer-patches, then run bin/magento setup:upgrade.
amasty-seo-toolkit-redirect-router-performance.patch on GitHub · raw file
All our patches, with a short guide to applying them, are in the paxento/magento-patches repository on GitHub.
The full diff:
diff --git a/Model/Redirect/RedirectGetter.php b/Model/Redirect/RedirectGetter.php
index ff74c61..56b86b5 100644
--- a/Model/Redirect/RedirectGetter.php
+++ b/Model/Redirect/RedirectGetter.php
@@ -44,7 +44,7 @@ class RedirectGetter
*/
public function getRedirect(string $path): ?RedirectInterface
{
- $collection = $this->getCollection();
+ $collection = $this->getCollection($path);
$resultRedirect = null;
foreach ($collection as $redirect) {
if ($this->isValidRedirect($redirect->getRequestPath(), $path)) {
@@ -71,12 +71,14 @@ class RedirectGetter
}
/**
+ * @param string $requestPath
* @return \Amasty\SeoToolkitLite\Model\ResourceModel\Redirect\Collection|void
*/
- private function getCollection()
+ private function getCollection(string $requestPath)
{
return $this->collectionFactory->create()
- ->addFieldToFilter(RedirectInterface::STATUS, 1)
+ ->addRequestPathFilter($requestPath)
+ ->addStatusFilter(1)
->addStoreFilter((int)$this->storeManager->getStore()->getId())
->setOrders([
RedirectInterface::PRIORITY => Collection::SORT_ORDER_ASC,
diff --git a/Model/ResourceModel/Redirect/Collection.php b/Model/ResourceModel/Redirect/Collection.php
index 7582e82..fde34f6 100644
--- a/Model/ResourceModel/Redirect/Collection.php
+++ b/Model/ResourceModel/Redirect/Collection.php
@@ -50,4 +50,35 @@ class Collection extends \Magento\Framework\Model\ResourceModel\Db\Collection\Ab
return $this;
}
+
+ /**
+ * @param int $status
+ * @return $this
+ */
+ public function addStatusFilter(int $status): self
+ {
+ $this->addFieldToFilter(RedirectInterface::STATUS, $status);
+
+ return $this;
+ }
+
+ /**
+ * @param string $requestPath
+ * @return $this
+ */
+ public function addRequestPathFilter(string $requestPath): self
+ {
+ $requestPath = trim($requestPath, '/');
+ $condition = new \Zend_Db_Expr("? REGEXP CONCAT('^', REPLACE(main_table.request_path, '*', '[[:alnum:]]*'))");
+
+ $select = $this->getSelect()
+ ->where($condition, $requestPath)
+ ;
+
+ if ($path = explode('/', $requestPath)[0] ?? '') {
+ $this->addFieldToFilter('main_table.request_path', ['like' => "{$path}%"]);
+ };
+
+ return $this;
+ }
}
diff --git a/etc/db_schema.xml b/etc/db_schema.xml
index c73e610..ac2d305 100644
--- a/etc/db_schema.xml
+++ b/etc/db_schema.xml
@@ -25,6 +25,9 @@
<constraint xsi:type="unique" referenceId="AMASTY_SEOTOOLKIT_REDIRECT_REDIRECT_ID">
<column name="redirect_id"/>
</constraint>
+ <index referenceId="AMASTY_SEOTOOLKIT_REDIRECT_REQUEST_PATH">
+ <column name="request_path"/>
+ </index>
</table>
<table name="amasty_seotoolkit_redirect_expiration" resource="default" engine="innodb" comment="amasty_seotoolkit_redirect_expiration">
