Skip to content
CH SCShop classgeneral · advanced · ~180 min · 8 steps

How to profile a slow website end to end: DNS to database

The full-stack performance investigation: decompose TTFB with curl, isolate the slow layer, read cache headers like a detective, profile queries with EXPLAIN, and prove the fix with numbers.

August 10, 2026 · by Dane Petersen

The speed-up lessons cover the common fixes. This lesson is for when the common fixes didn’t work, or when “the site is slow” needs to become a diagnosis before anyone spends money. The method is layer isolation: a request crosses DNS, TLS, the edge, the web server, the application, and the database — and one of those layers is your problem. Guessing rearranges deck chairs; measurement finds the layer, then the culprit inside it. This is the same investigation our performance audit packages — here’s how to run it yourself.

You’ll need a terminal, SSH access to the server, and browser DevTools. Expect to spend most of your time not fixing anything — that’s correct; the fix is usually twenty minutes once the diagnosis is right.

Establish the number before touching anything

“Slow” must become a number or you can’t prove improvement later. Measure the same URL ten times from a consistent network:

for i in $(seq 1 10); do
  curl -s -o /dev/null -w "%{time_total}\n" https://example.com/
done

Record the median, not the best. Then get the field reality — lab tests on your fast laptop routinely miss what phones on cellular experience. PageSpeed Insights’ “Core Web Vitals Assessment” section (when present) is real-user data; treat it as the ground truth your lab numbers must explain.

Decompose the request with curl’s stopwatch

One command splits a request into its phases:

curl -s -o /dev/null -w "dns: %{time_namelookup}\nconnect: %{time_connect}\ntls: %{time_appconnect}\nttfb: %{time_starttransfer}\ntotal: %{time_total}\n" https://example.com/

Read it like a triage nurse. dns high (>0.1s repeatedly): DNS problem — rare, but real on misconfigured resolvers. connect/tls high: network distance or TLS setup — a CDN usually erases this. ttfb minus tls high: the server is thinking — this is the application/database layer, and it’s the culprit in most CMS investigations. total minus ttfb high: payload weight — you’re shipping too many bytes, a front-end problem. Most “slow site” complaints resolve into one of the last two, and everything from here splits on which one you’ve got.

If TTFB is high: find out who’s answering — cache or application

A high TTFB has two very different meanings: the cache answered slowly (almost never) or the cache didn’t answer and PHP did (almost always). Interrogate the headers:

curl -sI https://example.com/ | grep -iE "cache-control|age|x-cache|cf-cache-status|x-drupal-cache|x-varnish"

The tells: CF-Cache-Status: HIT (or Age: above zero) means the edge served it — if that’s still slow, the problem is TLS/distance, not your server. MISS, BYPASS, or no cache headers at all mean every request runs the full application, and your real question becomes why isn’t this page cacheable? The classic answers: a session cookie set for anonymous users (one bad module can do it sitewide), Cache-Control: private or max-age=0 from the CMS, or query-string noise (?fbclid=...) fragmenting the cache. Fixing cacheability is routinely worth 10× more than any PHP optimization — a 900ms TTFB becomes 40ms when the edge answers.

Reproduce the slow request on the server itself

Remove the network from the equation by asking the server about itself:

curl -s -o /dev/null -w "%{time_total}\n" -H "Host: example.com" http://127.0.0.1/

Fast locally but slow publicly: the problem is in front (network, TLS, edge misconfiguration). Slow locally too: the application is genuinely slow, continue down. Also check whether slowness is constant or episodic — watch uptime load averages and run the curl loop during a slow episode. Episodic slowness with load spikes points at cron stampedes, backup jobs, or traffic bursts; constant slowness points at code and queries.

Open the database’s confession booth: the slow query log

The database is the usual suspect behind application slowness. Make it confess:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

Let it run through real traffic for an hour, then read the log (or summarize with pt-query-digest if available). Take the worst query and put it on the table:

EXPLAIN SELECT ... \G

The two felonies to look for: type: ALL (full table scan — usually a missing index on a column in WHERE/JOIN/ORDER BY) and rows: in the hundreds of thousands on a query that returns twelve. On CMS sites the repeat offenders are unindexed custom-field queries, reference-heavy listing pages, and search queries that should have been handed to a real search backend years ago. An ALTER TABLE ... ADD INDEX guided by EXPLAIN is regularly the single highest-leverage line of the entire investigation — and also the easiest to get wrong, so test it on a copy and measure before/after.

Profile PHP when the queries come back innocent

If the slow log is quiet but PHP time is high, profile the request. With Xdebug on a local copy:

ddev xdebug on   # then trigger the slow page with XDEBUG_TRIGGER set

Open the cachegrind file in a viewer and read the inclusive time tree. You’re hunting three patterns: an expensive call inside a loop (N+1 entity loads wearing a trench coat), an external HTTP call on the critical path (an API request made synchronously on every page view — move it to cron or cache it), and cache rebuilds happening per-request because something invalidates too aggressively. Each has the same shape in the profile: one subtree eating 60% of the wall time. Fix the subtree, not the twigs.

If the payload is the problem: weigh the page honestly

When TTFB is fine but total time isn’t, open DevTools → Network, disable cache, reload, and sort by size. The usual convicts, in order: images served at 4000px for a 400px slot (fix with responsive sizes and modern formats), a JavaScript bundle carrying a whole framework for one carousel, third-party tags (each chat widget, heatmap, and pixel bills the visitor), and web fonts loading six weights when the design uses two. Measure, cut, remeasure — the colophon approach of shipping JS only where something moves is the endpoint of this ladder.

Prove the fix and set the tripwire

Re-run the exact measurement from step one — same URL, same network, same ten-sample median — and write both numbers down next to what you changed. One change at a time, measured each time; two changes at once means you don’t know which one worked, and performance work without numbers converges on folklore. Then set the tripwire so regressions announce themselves: an uptime monitor with a response-time threshold, or Core Web Vitals monitoring on a maintenance plan, which is how we catch a client’s regression the week a deploy causes it instead of the quarter a customer complains. Slow returns quietly; instruments don’t blink.

That's the lesson. Back to the shop for more — or if this is the chore your organization never gets to,that's literally what we're for.