Case study · WooCommerce migration

The problem wasn’t the images

A US home-fixtures retailer was sitting at 12.9 GB on a 10 GB hosting plan, unable to finish a migration it had already started. The obvious fix was to compress the product photos. The product photos were not what was filling the disk.

12.9 → 9.30 GB
Total footprint
~50 min
Cutover window
7,873
Orders reconciled
0
Records lost
The constraint

A budget that ruled out the easy answer

The store runs about 1,500 products on WooCommerce. It had been moved to a managed host but the launch had stalled, because the site would not fit inside the plan it had been moved onto. Meanwhile the business was still paying roughly $249 a month to its previous host for a service that, in the owner’s words, only did the basics.

The ceiling was $250 a month for everything. Hosting and outside help combined. That single number is what makes this engagement worth writing about, because it removed the two shortcuts almost everyone reaches for first. We could not upgrade to a bigger plan, and we could not throw hours at the problem. We had to know precisely what those 12.9 GB were made of before touching anything.

Constraints are useful. A bigger plan would have hidden the actual problem for another two years.


Finding 01

2.9 GB of images that no browser ever requested

Uploads accounted for 11.24 GB, nearly the entire site. Every instinct says compress the product photography. That instinct is wrong, and understanding why is the single most useful thing in this article.

Since version 5.3, WordPress applies a “big image” threshold. When you upload anything wider than 2560 px, WordPress generates a resized copy at 2560 px, saves it as filename-scaled.jpg, and serves that file to visitors. The file you actually uploaded is kept on disk purely as a source for regenerating thumbnails later. It is never sent to a browser. Compressing it changes nothing that any customer will ever see, and it changes nothing about page speed.

On this site there were 3,326 of those retained originals, totalling 2.9 GB. They averaged 915 KB each. The versions actually being served averaged 125–140 KB.

What we did

Resized only the retained originals, leaving every served derivative untouched.

What it recovered

Roughly 2.5 GB, with zero change to any customer-facing image.

What it cost the site

Nothing. No visual regression, no re-crop, no SEO exposure.

The correct question on a bloated WooCommerce install is not “how do I compress my images.” It is “which of my files are actually being served?” Those are very different sets, and on any store with a few years of history behind it the gap between them is usually where the disk went.


Finding 02

Four years of scheduled work that had silently failed

WooCommerce runs a great deal of its housekeeping through Action Scheduler. Subscription renewals, stock holds, webhook deliveries, cleanup routines. When it stops running, nothing announces itself. The store keeps taking orders and the queue quietly fills up.

This one held more than 92,000 failed jobs dating back to 2022. Scheduled work had been broken for four years on the previous host and nobody had a reason to look. Moving to infrastructure where cron actually fires fixed it on day one.

Then it caused a problem, which is the part worth reading.

With the scheduler working again, a WooCommerce setting that had never been able to fire started firing. hold_stock_minutes was set to 60, meaning any unpaid order older than an hour gets cancelled automatically and its stock released. That is sensible behaviour for a store taking card payments at checkout. It is destructive for a store that also issues invoices manually and waits days for payment. Working infrastructure began auto-cancelling legitimate four-figure invoices, one of them twice.

A migration does not only fix things. It changes which things run. Audit what starts working, not just what breaks.

We disabled the hold and restored the affected orders. The broader lesson stands for any move onto better infrastructure: every dormant setting in that database was written by someone who assumed it would take effect. Some of them are about to.


Finding 03

Two million payment tokens, of which 486 were real

The database held 2,018,466 stored payment tokens. The store has 486 customers with a saved card. One each. Everything else was automated card testing: stolen card numbers being validated in bulk against a live merchant account to find out which ones still work.

The entry point was /my-account/add-payment-method/. It had taken 51,330 requests and 913 MB of bandwidth, making it the single heaviest consumer of bandwidth on a 1,500-product ecommerce site. Attackers created throwaway accounts in bursts, with account-creation timestamps matching failed order attempts to the minute, then submitted cards in volume.

2,018,466
Tokens removed
14,129,262
Meta rows removed
2 GB → 473 MB
Database size
0
New tokens after fix

Here is the detail that matters to anyone else running WooCommerce. The site already had bot protection installed. Cloudflare Turnstile was active and correctly protecting checkout, login and registration. It has no option covering the add-payment-method endpoint. The protection was real, it was configured properly, and the attack walked straight past it through the one door the integration does not offer to lock.

We closed it at the edge instead, with an IP block and a Cloudflare Managed Challenge rule scoped to that path. New fraudulent tokens since mitigation: zero. Then we cleared the accumulated rows, which by itself took the database from 2 GB to 473 MB.

Worth saying plainly: this had nothing to do with the host. It would have happened on any infrastructure. What it needed was somebody actually reading the bandwidth report.


The cutover

Fifty minutes, and the three things that would have broken it

By the time we were ready to launch, the copy of the site on the new host was 12 days stale. It was 150 orders and 11 products behind the version customers were still buying from. The standard answer is to re-migrate. We rejected it, because a fresh copy would have dragged 11 GB of uncompressed uploads back across and erased the cleanup that was the entire point of the project.

So we decoupled the two halves. Files stayed clean on the new host. The database came across fresh at cutover, with the database-side cleanup re-applied afterwards by a script written to be idempotent, so it could be run against the rehearsal and the real thing without behaving differently. Both hosts displayed a maintenance page throughout, which meant no visitor could reach stale data and no order could land on the wrong server. We rehearsed the whole sequence against real production data before running it for real.

Three things surfaced during that rehearsal, and any one of them would have made for a bad Tuesday.

Version drift

Production had moved ahead of the clone: a newer WordPress plus 11 updated plugins. Importing a newer database onto older files is a genuine breakage mode. A scripted version diff caught it before the import.

Encrypted secrets

The transactional email plugin encrypts its API key against the wp-config salts. The new platform generates its own. The imported ciphertext decrypted to garbage.

URL integrity

We MD5-hashed all 1,494 product slugs on both hosts and compared the sets. Identical. No redirects needed, no SEO exposure.

The second one deserves expanding, because of how it fails. wp_mail() simply returned false. No exception, no admin notice, nothing in the WordPress log. Order confirmation emails would have stopped completely and silently, while customers continued to pay and receive nothing. The fix was to carry the original salts across, which had a second benefit we did not initially plan for: every logged-in customer session survived the cutover intact.

The move itself took about 50 minutes. Order reconciliation came out exact, with 7,873 orders on both sides and the last order ID matching at 39564. DNS was an apex CNAME to the host’s custom hostname, proxied through the client’s own Cloudflare account, and propagation was measured at under 30 seconds.


The lesson worth keeping

On a managed platform, wp-config is not yours

The email plugin broke a second time, later, for a different and entirely legitimate reason. A routine security action regenerated the salts. Same encrypted key, same garbage, same silent failure.

At that point the instinct is to re-enter the API key and move on. We did not, because the failure would simply queue up again the next time anything touched that file.

The durable fix was not to repair the integration. It was to remove the dependency.

We moved transactional email onto the platform’s own mailer, which holds no salt-derived secret and therefore cannot fail this way at all. On managed hosting, wp-config.php is infrastructure, not your file. Anything that encrypts a secret against it needs either a deliberate pin or a design that does not care. Fix the class of problem, not the instance of it.


The commercial shape

The same $250, spent differently

The budget never moved. What changed was what it bought. Instead of $249 to a single provider doing the basics, the spend split into $35 for managed hosting and $215 for the expertise to use it. Same monthly number, materially different outcome.

That split is worth considering on its own merits. Hosting has become cheap and very good. Knowing what to do with it has not. Most stores in this position are not underspending. They are spending the whole budget on one thing and calling it two.

An honest note on attribution, because it would be easy to overclaim here. Most of what we found was host-independent. The card testing, the retained image originals and the misconfigured email authentication would have been true anywhere, and no host would have flagged them. Two things were genuinely infrastructural: the Action Scheduler backlog only surfaced once it ran on a platform where cron works, and the edge configuration and support during the DNS work made that half of the project uneventful. That is the accurate division, and it is a better argument than a bigger one.

Final footprint: 8.83 GB of files plus a 473 MB database. 9.30 GB, inside a 10 GB plan, with room to grow.

Four things to check on your own store

Tonight, in about twenty minutes

If any of those numbers surprised you, the interesting question is not how long it has been true. It is what else nobody has had a reason to look at.


Grafica Group

WooCommerce migrations, performance and ongoing maintenance.