Skip to content

Added cost

Every extension in this repository is measured on the same fixed set of pages of a stock OpenCart store, twice: once without it installed, and once with it installed and switched on. The difference is its added cost, and each extension's is published on its own What it costs page, linked from the table below.

The claim attached to those pages is narrow on purpose, and it does not say this extension is fast. It says what one request cost before, what it cost after, and which store it was measured on. This page lists everything the measurement does not cover.

How a number is taken

A roster row is one page request against a fixture store: the home page, a category, a product, the cart, the checkout, three signed-in account pages, the 404, and seven core admin screens. The list lives in this repository's own source and is identical for every extension, which is what makes two of these pages comparable and a page an extension never touches worth publishing as a +0 instead of leaving out.

Three units are recorded per row, and all three are gated at ±0: a number that moves has to be committed before the build is green.

Unit What it counts
Added queries SQL statements the page ran, counted inside the store
Added HTML bytes the size of the document the store sent back
Added requests images, scripts and stylesheets the document asks the browser to fetch

Two properties make +0 mean something here. The first is that both halves of every subtraction come from the same store on the same run, so anything the extension did not change cancels. That is why the stock denominator is printed in the same cell as the added figure rather than on a page of its own. The second is that a number is never rounded, bucketed or expressed as a percentage of the stock cost. A ratio drifts as OpenCart's own page cost drifts, and it licenses a larger absolute cost on exactly the pages that are already the most expensive.

Only pages a stock store already has are measured. Added cost means nothing without a counterfactual, so an extension's own admin screen (where every query would be an added one) is not a row, and neither is cron, nor an API route, nor what happens during installation.

How long a request takes

No millisecond figure is published on any of these pages, and that is a finding rather than an omission.

Wall-clock time was measured while this gate was being built, and it could not see the costs the query counter sees. A row costing about a tenth of a whole page's queries moved the warm median by less than the store's own run-to-run spread on the same machine, and across the rows that charged anything at all the measured time difference came out negative more often than positive. That is not a fast result; it is an unusable instrument. A number that changes sign depending on which run you take it from cannot be used to say a cost is small, and publishing it anyway would be publishing noise with our name on it.

So the time claim on this page is a negative one: the added time was too small for this rig to distinguish from the store's own variation. The query, byte and request counts are what we publish instead, because they are exact, reproducible and re-checkable by a person with the same store, and because a query count is the number that grows when your catalogue does, which is the case a stopwatch on a demo store is least able to see.

Nothing committed in this repository holds a millisecond figure, deliberately: a snapshot carrying one would fail its own check on every run and on every machine, while every measured number in it held.

Where that finding was taken, and what keeps it current: nothing. The timings behind this section were taken while this gate was being designed, against OpenCart 4.1.0.3 and the same fixture every other number here uses, on a store of the kind the measuring pass builds: PHP 8.3 under Apache with MySQL 8.0, from this repository's own docker/docker-compose.yml. The exact machine and the exact day were not written down, which we would rather tell you than reconstruct.

That is the whole provenance of this section, and it is kept by hand. It is the only claim on any of these pages that is. No committed file holds a millisecond, so nothing re-measures it, nothing ages it against the release the rest of these numbers are pinned to, and no build will go red on the day it stops being the truth. Every other figure you can read here came off a snapshot a build checks. This section did not, and it is worth less if you do not know that.

What each extension costs

Worst here means the most added queries on a single row, ties broken by roster order, because queries are the unit that multiplies with the size of a real store. The other two units are on each extension's own sheet, row by row. The shopper-facing column is separate from the overall one because an admin cost is paid by one person a few times a day and a storefront cost is paid by everybody.

Not measured in the first column means exactly that. The rig builds one release for every extension at once, because these sheets are only comparable (and only addable, as they are further down this page) against one set of stock figures. An extension that refuses to install on that release cannot be measured by it, and a pass run anyway would measure a store the extension was never on and report nineteen zeroes. Such an extension is on this table with no numbers on it, its own sheet says which release and why, and it is left out of the two totals below rather than added in as nothing.

Extension Pages charged Worst shopper-facing row Worst row Its sheet
Abandoned Cart Recovery 7 of 19 +0 queries on every row +0 queries on every row What it costs
Advanced Product Filters 9 of 19 Category — Desktops, +12 queries on a stock 230 Category — Desktops, +12 queries on a stock 230 What it costs
B2B Pricing 19 of 19 Wish list, +7 queries on a stock 184 Wish list, +7 queries on a stock 184 What it costs
Back In Stock 8 of 19 Product — Apple Cinema 30, +19 queries on a stock 202 Product — Apple Cinema 30, +19 queries on a stock 202 What it costs
Delivery Date 9 of 19 Checkout, +3 queries on a stock 191 Checkout, +3 queries on a stock 191 What it costs
Gift Cards Not measured — — What it costs
Hello World 0 of 19 +0 queries on every row +0 queries on every row Gated; no documentation section to publish it in
Import/export 7 of 19 +0 queries on every row Admin · Product form, +1 query on a stock 78 What it costs
Loyalty 7 of 19 +0 queries on every row +0 queries on every row What it costs
Pre-Order 15 of 19 Home, +2 queries on a stock 182 Admin · Product form, +3 queries on a stock 78 What it costs
Product Bundles 19 of 19 Category — Desktops, +11 queries on a stock 230 Category — Desktops, +11 queries on a stock 230 What it costs
Product Configurator 9 of 19 Product — Apple Cinema 30, +3 queries on a stock 202 Product — Apple Cinema 30, +3 queries on a stock 202 What it costs
Product Feed 7 of 19 +0 queries on every row +0 queries on every row What it costs
Product Search 8 of 19 Search — one hit, +7 queries on a stock 214 Search — one hit, +7 queries on a stock 214 What it costs
Profitability Copilot 7 of 19 +0 queries on every row Admin · Product form, +1 query on a stock 78 What it costs
Returns Portal 19 of 19 Order history — one order, +2 queries on a stock 204 Order history — one order, +2 queries on a stock 204 What it costs
Review Requests 8 of 19 Product — Apple Cinema 30, +2 queries on a stock 202 Product — Apple Cinema 30, +2 queries on a stock 202 What it costs
SEO Suite Pro 7 of 19 +0 queries on every row +0 queries on every row What it costs
Wishlist 8 of 19 Wish list, +13 queries on a stock 184 Wish list, +13 queries on a stock 184 What it costs

Every extension in the repository is on that table, including the empty scaffold we build new extensions from, which is measured like the rest and has no documentation section of its own to publish a sheet in.

What all of them cost together

The table below is arithmetic over the committed snapshots, not one more measurement. Each extension was measured on its own, and these are those numbers added up. A store running all of them would pay approximately this; it has never been measured in that state, and the difference between costs added together and costs paid at once is not something this page can tell you the size of. It is reported for one reason: it is the only place the pile-up shows, and a pile-up on a shared page is owned by nobody.

It is reported and never gated. Nothing about this total can fail a build: gating it would make every extension's release depend on every other extension's cost.

222 added queries across the 19 rows, if one store ran all 18 extensions at once.

The heaviest single row is Admin · Product form: +21 queries on a stock 78 and +64,330 bytes of HTML on a stock 168,926, from the 17 extensions that each add something to it.

Page Added queries Added HTML bytes Added requests
Home +18 on 182 +28 on 29,918 +0 on 25
Category — Desktops +29 on 230 +19,369 on 41,887 +2 on 20
Product — Apple Cinema 30 +38 on 202 +690 on 52,779 +0 on 18
Search — one hit +15 on 214 +28 on 29,984 +0 on 9
Manufacturer — Apple +28 on 210 +16,567 on 38,760 +2 on 18
Information — About Us +3 on 159 +28 on 17,431 +0 on 8
Cart +11 on 186 +70 on 56,590 +0 on 9
Checkout +11 on 191 +70 on 76,771 +0 on 9
Account +6 on 178 +98 on 25,400 +0 on 9
Order history — one order +11 on 204 +188 on 32,822 +0 on 9
Wish list +23 on 184 +2,328 on 26,659 +0 on 10
Not found +3 on 157 +28 on 17,710 +0 on 8
Admin · Dashboard +0 on 38 +7,616 on 45,936 +0 on 14
Admin · Product list +1 on 37 +7,616 on 52,997 +0 on 19
Admin · Product form +21 on 78 +64,330 on 168,926 +0 on 17
Admin · Order list +1 on 28 +7,968 on 36,788 +0 on 9
Admin · Order +3 on 60 +8,000 on 196,753 +0 on 9
Admin · Customer form +0 on 47 +9,971 on 56,390 +0 on 9
Admin · Settings +0 on 32 +12,900 on 222,055 +0 on 12

What these numbers do not cover

The roster only ever asks for a page. It never places an order, saves a product or deletes a customer, because a request that changes something changes the store the next row would have been measured against. The two stores diverge after the first one, and the row stops being re-runnable. Making those rows measurable needs a database snapshot restored between them, which this rig does not do.

So some of an extension's code is never on the clock, and rather than describe that in general terms, here is every file of it with the reason it is not reachable. Each extension's own sheet prints its share of this list.

70 handler files: 26 reachable only through a request that changes something, and 44 on a surface a stock store does not have.

Handler file Why no row reaches it
abandoned_carts/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
abandoned_carts/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
abandoned_carts/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
b2b_pricing/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
b2b_pricing/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
b2b_pricing/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
back_in_stock/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
back_in_stock/admin/controller/event/stock.php only an admin product save reaches it
back_in_stock/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
back_in_stock/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
back_in_stock/catalog/controller/event/stock.php only an order write or a direct quantity write reaches it
delivery_date/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
delivery_date/catalog/controller/event/order.php only an order write reaches it
gift_cards/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
gift_cards/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
loyalty/admin/controller/event/customer.php only creating or deleting a customer in admin reaches it
loyalty/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
loyalty/admin/controller/event/reward.php only removing an order's reward points in admin reaches it
loyalty/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
loyalty/catalog/controller/event/customer.php only an approved GDPR erasure reaches it
loyalty/catalog/controller/event/order.php only an order write reaches it
loyalty/catalog/controller/event/redemption.php only an order write, or the success page that follows one, reaches it
loyalty/catalog/controller/event/signup.php only creating a storefront account reaches it
preflight/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
preflight/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
preorder/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
preorder/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
preorder/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
preorder/catalog/controller/event/order.php only an order write reaches it
product_bundles/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
product_bundles/admin/controller/event/portal.php a view hook on another extension's admin screen, which a stock store does not have
product_bundles/admin/controller/event/store.php only deleting a store reaches it
product_bundles/catalog/controller/event/cart.php only adding a product to the cart reaches it
product_bundles/catalog/controller/event/editor.php an API route, which a stock store has no page for and the roster opens no session against
product_bundles/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
product_bundles/catalog/controller/event/order.php only an order write reaches it
product_configurator/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
product_configurator/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
product_feed/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
product_feed/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
product_feed/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
product_filters/admin/controller/event/category.php only saving or deleting a category in admin reaches it
product_filters/admin/controller/event/filter.php only saving a filter group in admin reaches it
product_filters/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
product_filters/admin/controller/event/placements.php only uninstalling a module, which every update does, reaches it
product_filters/admin/controller/event/product.php only an admin product save, copy or delete reaches it
product_filters/catalog/controller/event/order.php only an order write reaches it
product_filters/catalog/controller/event/special.php the Specials page, which a stock store has and the roster does not measure; its one added rows query on OpenCart 4.1 is stated on the extension's own limits page
product_search/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
product_search/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
product_search/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
profitability_copilot/admin/controller/event/cost_capture.php only an admin product save reaches it
profitability_copilot/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
profitability_copilot/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
profitability_copilot/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
profitability_copilot/catalog/controller/event/snapshot.php only an order write reaches it
returns_portal/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
returns_portal/admin/controller/event/tombstone.php only deleting a return reaches it
returns_portal/catalog/controller/event/door.php only opening or saving a return request reaches it
returns_portal/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
returns_portal/catalog/controller/event/restock.php only an order status change reaches it
review_requests/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
review_requests/admin/controller/event/review.php only saving a review in admin reaches it
review_requests/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against
seo/admin/controller/event/capture.php only an admin catalogue save or delete reaches it
seo/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
seo/catalog/controller/event/language.php this extension's storefront strings, which load only where it puts one of its own strings on a page, and no roster row on the demo+seed fixture does
wishlist/admin/controller/event/customer.php only deleting a customer reaches it
wishlist/admin/controller/event/personal_data.php the data-protection screen's collector, which fires only on the aggregated personal-data page — an admin screen that exists because these extensions do, so a stock store has no counterfactual for it and it is not a row
wishlist/catalog/controller/event/api.php an API route, which a stock store has no page for and the roster opens no session against

Two more classes need no list, because the trigger alone decides them and a list would only be a list of everything the rule already matches:

Trigger Why no row reaches it
cron, cli a cron or CLI handler, which no page of a stock store has a counterfactual for
admin/view/mail/…, catalog/view/mail/… a mail render, which fires inside a checkout or an order-status write the rig cannot drive

This list is checked in both directions on every build. A registered handler that no roster row reaches and that nobody has written a reason for is a build failure, and so is an entry on this list that turns out to run after all. There is no flag and no suppression mode: an exclusion is either still true, or the build is red.

The limits

There is no ceiling, and nobody can grant an exemption from one. No number on any of these pages is compared against a threshold. Every hard number this repository enforces comes from somewhere outside it (a marketplace field limit, a standard's key-length floor), and a ceiling on added cost would be the first one we invented for ourselves. It would also have missed the two worst findings we have: the heaviest thing on the summed table is bytes on an admin page that no single extension is responsible for, and a per-tile cost hides under any per-page threshold. In place of a ceiling there is a second publication: the two tables above.

The pile-up, with its number: Admin · Product form, where all 18 of them together add +64,330 bytes of HTML to a stock 168,926 — bytes, on an admin page, that no one extension is answerable for and that no per-extension ceiling would have caught.

The largest cost any single extension charges on any single row: Back In Stock's +19 queries on a stock 202, on Product — Apple Cinema 30. A ceiling above that number refuses nothing, and a ceiling below it is a remediation ticket wearing a ceiling.

Catalogue scale is the most important limit on this page. Every row is measured at one fixed shape: the category page carries ten product tiles, the cart one line, the order two items. Where an extension's cost is per tile rather than per page, the number on its sheet is the cost at ten tiles, and a merchant whose category pages show a hundred products pays roughly ten times it on that row. Nothing in this gate can see that, because the fixture has one shape and a cost measured at one shape cannot be divided into a fixed part and a per-item part. If you run large category pages, read the Scales with column before the numbers beside it.

Requests a page makes for itself are counted; requests a script makes later are not. The added-requests figure is a difference between the subresources the two documents declare. A URL that only exists inside JavaScript, or a fetch a script makes after the page has loaded, is invisible to it.

The storefront rows were measured with SEO URLs switched on, and OpenCart ships them off. config_seo_url is one of two things the fixture changes from what a fresh install leaves (the other is below), and it is changed because core seeds the keyword table in full, every category, product, manufacturer and information page a roster row asks for. Measuring with the lookup off would measure a core that no keyword-using store runs, and would make one extension unmeasurable outright. The consequence: if your own store has SEO URLs off, its storefront pages cost fewer queries than the stock column here says, and an added cost that comes from a keyword lookup is one you do not pay. The admin rows are unaffected.

The admin rows were measured with the store's storage directory moved, which is the move OpenCart's own dashboard asks you to make. It is the second and last thing the fixture changes from what a fresh install leaves, and it is there because of what the dashboard does while the directory is still where the installer put it: core prints the store's own filesystem path into that page, followed by one entry per parent directory above it. The dashboard's size then depends on where on the disk the store happens to sit rather than on anything in it, and the same store measured on two machines gives two numbers, 496 bytes apart between the two this gate is measured on. Moved, core prints neither, and the row is the same number anywhere. The consequence for you is that the stock figure in the Admin · Dashboard row is about 2,200 bytes smaller than the same page on a store whose storage directory has never been moved; no extension's added cost changes with it.

The fixture is core's own demo data plus the smallest core-shaped additions that make the signed-in rows work: one customer, one address, one order, one cart line, one wish list entry. Core-shaped is the whole rule, and it is a rule rather than a list: every row the seed adds is a row a stock store could already hold, and nothing an extension owns is ever added: no table of its own, and no setting of its own beyond the one that switches it on. That keeps every extension measured against the same store, and it means a feature that only appears on data the extension itself creates is measured on pages where that data is absent. Where that matters, the sheet's row says +0 and this sentence is the qualification on it.

The release these numbers were measured on is not the same list as the releases an extension is tested against. A What it costs page names one release, because the stock denominators come from that release's own code; an extension's compatibility claim is a separate list, in its Requirements page, recording which releases a full install-and-run pass survived. Neither implies the other.

A green build proves these pages are well-formed and current with the rig, not that the numbers are still true. The check every build runs is a coherence check: every extension has a snapshot, every snapshot covers every row of the current roster with real integers (or says outright that the rig cannot install that extension on the release it pins), and every published page matches the snapshot it was generated from. Re-measuring needs a store, and happens when somebody runs the measuring pass.

If a number moves

A published cost is published whatever it says. These pages are generated from the measurements and rebuilt on every change; there is no draft state and no version of them that omits a bad row.

A cost that rises stays risen until somebody optimises it. No window, no deadline, and no promise here that anybody will.

Nothing is pulled, at any number. No measurement triggers a withdrawal, a warning banner or a hold on a release.

The changelog names it. When a published number moves in either direction, the extension's Changelog page says which page, which unit, and from what to what. That is kept by hand, and no build check enforces it. This page says so because the sentence is worth less if you do not know that.

A row leaving the roster is treated the way a broken API promise is; a row joining it is not. A shorter roster is a smaller promise and gets argued for in the open. A new row that makes an extension look worse ships on the day it is written.