"The website is slow" can mean a dozen different things: a slow DNS lookup, an overloaded CPU, PHP workers waiting on a database query, or a large page served without compression. Guessing and tuning random settings rarely helps. In this tutorial you will measure where the time actually goes on an Ubuntu 24.04 server running Nginx, PHP-FPM and MySQL, working from the outside in until you find the layer responsible.
Prerequisites
To follow this tutorial you need:
- A server running Ubuntu 24.04 LTS with Nginx, PHP-FPM 8.3 and MySQL 8.0, for example a CubePath VPS. If you use Apache or MariaDB, the method is the same and only the configuration paths change.
- A non-root user with
sudoprivileges. - A website on
your_domainthat you can test. Replaceyour_domainwith your own domain throughout. - A second machine (your workstation is fine) to test from outside the server.
Step 1 - Measuring where the time goes with curl
Start by splitting a single request into its phases. curl can report the timing of each phase with its -w option. Run this from your workstation:
curl -o /dev/null -s -w 'dns: %{time_namelookup}s\nconnect: %{time_connect}s\ntls: %{time_appconnect}s\nttfb: %{time_starttransfer}s\ntotal: %{time_total}s\nsize: %{size_download} bytes\n' https://your_domain/
dns: 0.021s
connect: 0.058s
tls: 0.131s
ttfb: 2.874s
total: 2.912s
size: 48213 bytes
Each value is cumulative from the start of the request, so read the differences between lines:
- dns high (over 0.2 s): a DNS problem, not a server problem.
- connect minus dns high: network latency or packet loss between you and the server. Check with
mtr your_domain. - ttfb minus tls high: the server took long to generate the response. This is the most common case and what the rest of this guide investigates.
- total minus ttfb high: the response is large or the connection is slow. Look at page size and compression in Step 6.
In the example, the server needed about 2.7 seconds to produce the page. Now run the same request on the server itself, bypassing the network. The --resolve option sends the request to the local Nginx while keeping the correct hostname and TLS certificate:
curl -o /dev/null -s -w 'ttfb: %{time_starttransfer}s\n' --resolve your_domain:443:127.0.0.1 https://your_domain/
If the local TTFB is just as high, the delay is in the application stack. Compare a static file too, such as an image or robots.txt. If static files are fast and dynamic pages are slow, Nginx is fine and the problem is behind it in PHP or the database.
Step 2 - Checking server resources
Before looking at individual requests, check whether the server as a whole is short of CPU, memory or disk I/O. Compare the load average with the number of CPU cores:
uptime
nproc
11:02:45 up 12 days, 3:14, 1 user, load average: 7.82, 7.41, 6.90
2
A load average consistently above the number of cores means processes are queuing for CPU or waiting on disk. Use vmstat to see which:
vmstat 1 5
Read these columns across the five samples:
r: processes waiting for CPU. Constantly above the core count means CPU saturation.siandso: memory swapped in and out per second. Anything other than 0 during normal traffic means the server is out of RAM, which slows everything down.wa: percentage of CPU time spent waiting for I/O. Values above 10 to 20 point to a disk bottleneck.id: idle CPU. Near 0 together with a highusmeans the CPU is busy running code.
Check memory in a readable form. The available column is what matters, not free:
free -h
total used free shared buff/cache available
Mem: 3.8Gi 3.5Gi 112Mi 64Mi 402Mi 187Mi
Swap: 2.0Gi 1.4Gi 616Mi
This server is nearly out of memory and swapping heavily. Find the processes using the most memory and CPU:
ps -eo pid,user,%cpu,%mem,rss,comm --sort=-%mem | head -n 10
If wa was high, install sysstat and check per-disk utilization:
sudo apt install sysstat
iostat -xz 1 3
A device with %util close to 100 and high r_await or w_await values (milliseconds per request) is saturated. sudo iotop -o (from the iotop package) shows which process is doing the I/O.
Step 3 - Logging request times in Nginx
The default Nginx access log does not record how long requests take. Add a log format that includes $request_time (total time Nginx spent on the request) and $upstream_response_time (time spent waiting for PHP-FPM or another backend). Create a new file:
sudo nano /etc/nginx/conf.d/timing-log.conf
Add the format definition. Files in conf.d are included in the http block of /etc/nginx/nginx.conf:
log_format timing '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'rt=$request_time urt=$upstream_response_time';
Then open your site's server block, for example /etc/nginx/sites-available/your_domain, and add an access_log line inside the server { ... } block:
access_log /var/log/nginx/your_domain.access.log timing;
Test the configuration and reload Nginx:
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
After some traffic has been logged, list the slowest individual requests:
sudo awk '{for (i = 1; i <= NF; i++) if ($i ~ /^rt=/) print substr($i, 4), $7}' /var/log/nginx/your_domain.access.log | sort -rn | head -n 10
8.412 /wp-admin/admin-ajax.php
6.905 /shop/?orderby=price
2.771 /
Then calculate the average time and number of hits per URL, which shows where optimization pays off most:
sudo awk '{for (i = 1; i <= NF; i++) if ($i ~ /^rt=/) {t[$7] += substr($i, 4); n[$7]++}} END {for (u in t) printf "%.3f %6d %s\n", t[u] / n[u], n[u], u}' /var/log/nginx/your_domain.access.log | sort -rn | head -n 10
Compare rt and urt on slow lines. If they are nearly equal, the backend is slow. If rt is much larger than urt, the time was spent sending the response to a slow client or a large download, not generating it.
Step 4 - Finding PHP-FPM bottlenecks
PHP-FPM runs a limited pool of worker processes. When all of them are busy, new requests wait in a queue, and response times climb even though no single script is slow. PHP-FPM logs a warning when this happens:
sudo grep max_children /var/log/php8.3-fpm.log
[25-Sep-2026 10:41:07] WARNING: [pool www] server reached pm.max_children setting (5), consider raising it
Ubuntu's default of 5 workers is low for most sites. Before raising it, check how much memory each worker uses, because too many workers causes swapping, which is worse:
ps --no-headers -o rss -C php-fpm8.3 | awk '{s += $1; n++} END {printf "%d processes, average %.0f MB\n", n, s / n / 1024}'
6 processes, average 62 MB
Divide the memory you can spare for PHP by that average. For example, with 1.5 GB available for PHP and 62 MB per worker, about 24 workers is a safe ceiling. Edit the pool configuration:
sudo nano /etc/php/8.3/fpm/pool.d/www.conf
Set the process manager values and enable the slow log, which records a stack trace of any request that runs longer than the timeout:
pm = dynamic
pm.max_children = 24
pm.start_servers = 6
pm.min_spare_servers = 4
pm.max_spare_servers = 8
request_slowlog_timeout = 5s
slowlog = /var/log/php8.3-fpm-slow.log
Check the syntax and reload PHP-FPM:
sudo php-fpm8.3 -t
sudo systemctl reload php8.3-fpm
When a slow request occurs, the slow log shows the script and the function it was stuck in:
sudo tail -n 20 /var/log/php8.3-fpm-slow.log
[25-Sep-2026 11:15:32] [pool www] pid 41822
script_filename = /var/www/your_domain/index.php
[0x00007f1c2e813f40] curl_exec() /var/www/your_domain/wp-content/plugins/example/api.php:88
[0x00007f1c2e813e80] fetch_rates() /var/www/your_domain/wp-content/plugins/example/api.php:41
This example shows a plugin waiting on an external HTTP API during page generation. Stack traces ending in database functions such as mysqli_query() or PDOStatement->execute() send you to the next step.
Step 5 - Finding slow MySQL queries
MySQL can log every query that takes longer than a threshold. Enable the slow query log at runtime without restarting the server:
sudo mysql -e "SET GLOBAL slow_query_log_file = '/var/log/mysql/mysql-slow.log'; SET GLOBAL long_query_time = 1; SET GLOBAL slow_query_log = 'ON';"
The new long_query_time applies to connections opened after the change, which with PHP means new requests. To keep the setting after a restart, add these lines under [mysqld] in /etc/mysql/mysql.conf.d/mysqld.cnf:
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
Let the site run under normal traffic for a while, then summarize the log with mysqldumpslow, sorted by total time:
sudo mysqldumpslow -s t -t 5 /var/log/mysql/mysql-slow.log
Count: 214 Time=2.31s (494s) Lock=0.00s (0s) Rows=20.0 (4280), app[app]@localhost
SELECT * FROM orders WHERE customer_email = 'S' ORDER BY created_at DESC LIMIT N
mysqldumpslow replaces literal values with N and 'S' so identical queries are grouped. Run the query with real values through EXPLAIN to see how MySQL executes it:
sudo mysql your_database -e "EXPLAIN SELECT * FROM orders WHERE customer_email = '[email protected]' ORDER BY created_at DESC LIMIT 20\G"
type: ALL
possible_keys: NULL
key: NULL
rows: 1843022
Extra: Using where; Using filesort
type: ALL with key: NULL means a full table scan of 1.8 million rows. An index on the filtered and sorted columns fixes this:
sudo mysql your_database -e "ALTER TABLE orders ADD INDEX idx_email_created (customer_email, created_at);"
Run the EXPLAIN again. It should now show type: ref and the new index in key, with a much smaller rows estimate. On large tables, add indexes during low traffic, and take a backup first.
Step 6 - Checking compression and caching
If Step 1 showed a large gap between TTFB and total time, check what the server sends. Request a CSS or JavaScript file while announcing gzip support:
curl -sI -H 'Accept-Encoding: gzip' https://your_domain/css/style.css
HTTP/2 200
content-type: text/css
content-length: 184220
No content-encoding: gzip header means the file is sent uncompressed. Ubuntu's default /etc/nginx/nginx.conf enables gzip but leaves gzip_types commented out, so only HTML is compressed. Open the file:
sudo nano /etc/nginx/nginx.conf
In the http block, uncomment these lines in the gzip section:
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
Also let browsers cache static assets instead of downloading them on every page view. Add this location block inside your site's server block:
location ~* \.(css|js|png|jpe?g|gif|webp|svg|woff2?)$ {
expires 30d;
access_log off;
}
Test and reload, then repeat the curl request. It should now include content-encoding: gzip, and static files should return cache-control: max-age=2592000:
sudo nginx -t
sudo systemctl reload nginx
Step 7 - Verifying under load
Confirm your changes help under concurrent traffic, not only for a single request. ab (ApacheBench) from the apache2-utils package sends many requests in parallel. Install it on your workstation or a second server and only test sites you own:
sudo apt install apache2-utils
ab -n 500 -c 20 https://your_domain/
Requests per second: 41.87 [#/sec] (mean)
Time per request: 477.664 [ms] (mean)
Percentage of the requests served within a certain time (ms)
50% 452
95% 690
99% 843
100% 1012 (longest request)
Run the same test before and after each change and compare requests per second and the 95th percentile. While it runs, watch vmstat 1 on the server to see which resource reaches its limit first.
Troubleshooting
502 Bad Gatewayor504 Gateway Time-outduring slow periods: check/var/log/nginx/error.log.upstream timed outmeans PHP took longer thanfastcgi_read_timeout(60 s by default). Fix the slow script instead of raising the timeout.- The server is killing processes:
sudo journalctl -k | grep -i 'out of memory'shows OOM kills. Reducepm.max_childrenor MySQL'sinnodb_buffer_pool_size, or add RAM. - Everything is fast locally but slow for some visitors: the problem is on the network path. Run
mtr -rw your_domainfrom an affected location, and consider a CDN for static assets.
Conclusion
You measured request phases with curl, checked CPU, memory and disk pressure, logged per-request timings in Nginx, and traced slow requests down to PHP-FPM worker limits, slow scripts and unindexed MySQL queries. Working in this order keeps you fixing the real bottleneck instead of tuning settings at random. As next steps, enable PHP OPcache and a page cache for your application, and keep the timing log format so you can spot regressions early.
