The starting point
The store: OpenCart 4.0.2.3, 38,000 products, 11 languages, 62 extensions, on a 4-core VPS with Apache and no object cache. Category pages took 4.1 seconds to first byte at quiet times and timed out during campaigns. The owner’s brief was one sentence: “make it fast before Black Friday.”
- TTFB (category)
- 4.1 s
- Products
- 38,000
- Extensions
- 62
We treated it like an incident, not a redesign: measure, change one thing, measure again. Nothing was touched on the live store until the same change had run for a day on a staging copy with a full database dump. Below is the order we did things in and what each step bought.
0. Measuring before touching anything
Almost every “OpenCart is slow” ticket arrives with a theory attached — usually the hosting. Before we accept or reject the theory we need four numbers: time to first byte on the slowest page, database time inside that request, the number of queries per request, and Largest Contentful Paint in the browser. The first one takes ten seconds to collect:
curl -o /dev/null -s -w "ttfb=%{time_starttransfer}s total=%{time_total}s\n" \
"https://store.example/index.php?route=product/category&path=57"Running that twenty times in a loop, at different hours, tells you whether the page is uniformly slow or only slow when the cache is cold. It was uniformly slow. Next we turned on the MySQL slow query log for one working day, at a deliberately low threshold, so that “fast enough” queries would still show up if they ran hundreds of times per page:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
-- one day later, on the shell:
mysqldumpslow -s t -t 10 /var/log/mysql/mysql-slow.logAt the same time we read `storage/logs/error.log`. On this store it was 240 MB and full of PHP 8 deprecation notices from three extensions — not the cause of the slowness, but a reliable map of which extensions were still written for PHP 7.
- Time to first byte on the slowest category, product and search page.
- The ten slowest queries by total time, not by single execution.
- Queries per request — anything over 300 on a category page means an unindexed loop somewhere.
- LCP and total transferred bytes from a throttled browser profile.
1. Database: the missing indexes
OpenCart’s default schema is fine for a few thousand products. At 38,000 products with 11 languages, the product_description and product_to_category joins were scanning millions of rows. The slow query log showed the top three queries accounting for 61% of database time, and EXPLAIN confirmed full table scans on each of them.
ALTER TABLE oc_product_to_category ADD INDEX idx_cat_prod (category_id, product_id);
ALTER TABLE oc_product_description ADD INDEX idx_lang_name (language_id, name(64));
ALTER TABLE oc_product ADD INDEX idx_status_sort (status, sort_order, date_available);Three indexes, TTFB down to 2.6s. We also purged 1.9 million rows from two tables that had never been cleaned since the store opened — OpenCart writes to both on every request and neither is pruned automatically.
DELETE FROM oc_customer_online WHERE date_added < NOW() - INTERVAL 7 DAY;
DELETE FROM oc_session WHERE expire < NOW();
OPTIMIZE TABLE oc_customer_online, oc_session;That cleanup now runs as a nightly cron job. It is the single most common thing we find missing on stores that have been live for more than two years.
2. Cache: Redis instead of the file system
OpenCart’s file cache was writing thousands of small files per minute into `storage/cache/`, and a nightly cron was deleting the whole folder — so every morning the first few hundred visitors rebuilt the entire catalog cache by hand. Switching the cache engine to Redis and raising the expiry for category and manufacturer data removed most of the disk I/O and made the morning spike disappear.
3. The two extensions we removed
One “related products” module ran an unindexed query per product on every category page — 40 queries per page for a 40-product grid. Another SEO module rewrote every URL through a regex chain on each request instead of using the oc_seo_url table. Both were replaced with lighter, event-based implementations that hook into OpenCart 4 events rather than patching core files.
We audit extensions in a fixed order, because disabling things at random on a live store is how you lose a weekend:
- 1Copy the store to staging with a full database dump, so the query profile is realistic.
- 2Disable one extension, warm the cache, then measure TTFB and query count on the three slowest pages.
- 3Re-enable it and move to the next one — never disable two at a time, or you cannot attribute the difference.
- 4For anything that saves more than 100 ms, decide whether it is replaceable, rewritable or simply unnecessary.
Two extensions accounted for 1.3 seconds between them. TTFB after removing both: 1.3s.
4. Images and the frontend
Thumbnails were regenerated on the fly whenever `image/cache/` was cleared, which the same nightly cron was doing. The first visitor of the day was effectively running a batch image job in their browser tab.
- Pre-generated every catalog thumbnail size once, then stopped clearing the folder on a schedule.
- Served WebP through Nginx with a fallback for older clients.
- Added width and height attributes so the layout stops shifting while images load.
- Lazy-loaded everything below the fold, including the four carousels on the home page.
Largest Contentful Paint dropped from 5.8s to 1.4s, and the page weight of a category listing fell from 4.2 MB to 900 KB.
5. Server: Nginx, PHP-FPM, OPcache
The last step was replacing Apache with Nginx + PHP-FPM and giving OPcache a memory pool large enough for a codebase this size. On a store with thousands of PHP files, the default 128 MB pool silently evicts and recompiles files on every request.
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=30000
opcache.validate_timestamps=0
opcache.save_comments=1PHP-FPM was sized from real memory use rather than a template: average process size measured under load, then `pm.max_children` set so that all workers together fit in roughly 70% of RAM, with `pm.max_requests` set to recycle workers before any slow leak matters. Cloudflare went in front for static assets only — no full-page caching, because the cart and the currency switcher live in the same HTML.
TTFB settled at 0.6s at quiet times and stayed under 1.1s at Black Friday peak traffic, on the same 4-core VPS the store started on.
- TTFB (category)
- 0.6 s
- LCP
- 1.4 s
- Conversion
- +22%
What we deliberately did not do
Half of a performance engagement is refusing work. These were on the table and stayed off it:
- No theme rewrite. The template was not the bottleneck, and rewriting it would have made every measurement above unattributable.
- No move to a bigger server before the indexes were fixed — hardware would have hidden the problem for another year at four times the monthly cost.
- No full-page cache in front of the storefront while the cart lives in the same response.
- No platform migration. A store that answers in 0.6 seconds does not need to leave OpenCart.
Takeaways
Measure before you touch anything. Most OpenCart “slowness” is a handful of queries and one or two extensions, not the platform. Do the cheap, reversible things first — indexes, cleanup and cache — and only then consider new hosting. Every step above is reversible in under a minute, which is exactly why we could ship them one at a time on a live store during its busiest quarter.
The whole engagement took 11 billed hours across four days: three hours measuring, five hours on the database and extensions, three on the server profile.
If your category pages are over two seconds, or your admin has become unusable as the order table grew, send us the store URL and your hosting details. We start with the same measurement pass, and you get the numbers and a fixed list of recommendations before any work is booked. Work is billed by the hour at the launch rate of $10 / hour + VAT.
Speed, caching and CDN architecture; Core Web Vitals and profiling.