A membership and online-course business came to me with a site that took 6 to 7 seconds to load any page a member saw. Three people had already offered three reasons. Too many plugins. A bloated database. An old version of PHP.

None of them was the main problem. I found that by measuring, and I disproved eight confident diagnoses along the way. Two of those were mine.

  • Public page load~8 s to ~350 ms
  • Member page load6-7 s to 2.0-2.5 s
  • PHP cache hit rate0.28% to 96.88%
  • Database queries for one menu47 to 1

What was actually wrong

The site shared a server with three other businesses. That part is normal. The problem was one level down: all four sites shared a single PHP code cache, and it was far too small for the four of them.

PHP code cache, shared server vs. an isolated copy of the same site
MeasureShared serverIsolated copy
Cache hit rate0.28%96.88%
Time spent recompiling code, per requestabout 2.6 sclose to none

So the server was recompiling nearly the whole site on almost every request. You can't fix that on a shared box. The cache is sized once for everyone, and restarting it restarts everyone.

I didn't ask anyone to take that on faith. I rebuilt the fix on a throwaway server first and showed it working before proposing any move.

Quick wins first, on the old server

A hosting decision takes weeks. The owner shouldn't wait that long for anything to improve, so I fixed what I could where the site already lived.

  • A booking plugin started a session on every page, which made every page uncacheable. Removing that on public pages took them from about 8 seconds to about 350 ms.
  • A membership plugin ran 47 database queries to build something that needed 1. I fixed it and checked the output matched across all 23 taxonomies.
  • Eight simultaneous editor requests dropped from 15.97 s to 7.67 s.
  • Settings loaded on every request shrank from 2,357 KB to 1,323 KB.
  • A 6.8 GB debug log, still growing, got deleted.

Saying no to the expensive answer

A premium managed WordPress host was on the table. I priced it properly and recommended against it in writing. The tier this site needed was $700 a month. A dedicated server with the same specs was $170. That's about $6,400 a year for hosting that wouldn't have been meaningfully faster, because the host was never the problem.

The site moved to a dedicated server on an account the owner controls. Same size as before. The gain was having it to itself.

Changing one thing at a time

The original plan bundled the move with a PHP upgrade. I split them apart, and I think that was the most useful decision of the whole project.

The upgrade was worth about 6%. The dedicated cache was worth about 2.6 seconds per request, and it didn't need the upgrade. Bundling them would have meant proving every checkout and course flow on a new PHP version before anyone could set a date. The outgoing agency also told me an earlier upgrade attempt had blanked the site. So migration night changed one thing: the server.

Finding out what the owner actually owned

The outgoing agency cooperated throughout, and I still checked every statement against the site's own database. That turned up things nobody knew to ask about.

  • The booking suite, the most expensive piece on paper, was a lifetime license already registered to the owner. Renewal cost: $0.
  • Ten paid plugins had license records and no installation.
  • The chat widget was a sub-account inside the agency's marketing platform, with the branding stripped off. It would have died quietly the day that account closed.
  • Every order confirmation and password reset went out through a cloud account belonging to the agency's principal.

I also caught a false alarm of my own. A course plugin looked unlicensed, but the "invalid" flag came from a test copy where I'd deliberately blocked outbound traffic. The real setting said valid. That one was a step away from reaching the owner as bad news.

The DNS problem, and designing around it

The plan assumed I could shorten the DNS cache time to 5 minutes before the move. The registrar's panel had no control for it, and the records were pinned at 4 hours. Moving to a different DNS provider wouldn't reliably help either, because the registry itself caches that change for 48 hours.

So I stopped trying to shrink the delay and made the delay harmless. The old site would be frozen, unable to take orders or signups. Anyone still reaching it during the switchover would see a maintenance page. Nothing they did could be lost.

Copying the DNS zone turned up 21 records, including three email authentication records the new provider's import scan missed. I compared every record against the live answers before switching.

Cutover night

Everything that could be done ahead of time was. The security certificate was issued in advance, so nobody saw a browser warning after the switch. Scheduled jobs were installed but switched off. Server settings were confirmed through a real web request, because the command line reported different values than the site actually used.

The cutover window, starting at 11:00 PM
ElapsedStep
0:00Old site frozen. User, order and post counts recorded
1:36New files synced. Full database imported in 47 seconds
~3:00Counts re-checked on the new server: exact match
~4:00Mail and scheduled jobs switched on
~5:00DNS switched. Public resolvers already answering

Zero data loss was measured, not assumed. The counts recorded at the freeze matched the new server exactly, and all 516 of 516 database tables came across. Then I made a real purchase, with real money, and watched it go through end to end: payment captured, order completed, appointment booked, confirmation email in the inbox. I refunded it, which tested the payment path in the other direction.

The five-minute estimate came from a rehearsal. The first guess was 30 to 45 minutes. It took three practice runs to get the import clean.

After the move

Before and after the move
MetricBeforeAfter
Member pages6-7 s2.0-2.5 s
Public pages~350 ms~200 ms
PHP cache hit rate0.28%96%+
Downtime for visitorsabout 5 minutes
Server errors, first 12 hourszero

One near miss, caught early. The new server's code cache had room for 16,229 files. Real traffic filled 12,181 of them within hours. Left alone, it would have started recompiling within days, a milder version of the original problem. I raised the limit to 65,536 before it mattered.

Cutting the last cords

Moving a site is the easy part. The services it quietly depends on are where handovers go wrong, because they fail weeks later instead of on the night.

Transactional email moved to a relay on the owner's own hosting bill: $0.80 a month for 8,000 messages. I picked that tier from the real mail log, 1,732 messages in 15 days, with headroom, because a full quota stops mail without telling anyone. The first setup passed every visible test. The raw headers showed the provider adding one-click unsubscribe links to order confirmations. A customer who clicked one would have stopped getting password resets. Fixed before it shipped.

The chat widget moved to the owner's own account. My first swap answered in live-chat mode instead of as a bot, so I reverted it within minutes. That was cheap only because I'd taken a backup first.

Who owns what, before and after
ServiceBeforeAfter
HostingThe agency's shared serverThe owner's account
Domain and DNSA registrar the owner couldn't log intoThe owner's registrar
Transactional emailThe agency principal's cloud accountThe owner's account
Chat widgetThe agency's marketing platformThe owner's account
Theme and plugin licensesFour under agency accountsAll in the owner's name

Nothing breaks when the previous vendor closes their accounts. That's the actual deliverable. The speed is what people talk about.

Things nobody asked me to look for

  • A 5.3 GB full backup of the site, database included, sat in a public folder at a guessable filename. Removed.
  • A folder of paid downloads lost its access protection in the move, because the new server ignores the old rule file by design. A deliberate test caught it and the rule now lives in the server config.
  • A lead form had been silently broken for three weeks. It showed "check your inbox" whether or not the signup worked.
  • Analytics counted every visit three times. One property, three tracking tags.
  • Old credentials for services no longer in use were still sitting in the database.

What this doesn't prove

This is one site, and one night of post-move numbers at the time of writing. A week of data is still coming. The PHP upgrade and plugin cleanup are the next phases, and I've measured two of them on a copy (about 2.07 s and 1.70 s off the homepage) but not shipped them yet.

If your site sounds like this one, slow for reasons nobody agrees on, or tied to a vendor you're leaving, tell me about it. The first step is always measuring, and I'll tell you what I find even if the answer is that you don't need me.

Stack and skills

  • WordPress
  • PHP-FPM and OPcache tuning
  • Query profiling
  • Server migration
  • DNS cutover planning
  • SPF, DKIM and DMARC
  • Transactional email
  • Security review

Got a site that needs looking after?

Tell me what it runs on and what's bothering you. I'll reply within one business day, and the first conversation is free.

Tell me about your site or email joehahn@trudesign.dev