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.