To speed up WordPress, you need a sequence, not a single task. Until the infrastructure and the cache are in place, compressing an image or removing two plugins only shifts load time by a few hundredths of a second. A slowdown of several seconds usually comes from four or five specific places, not from some hidden secret. This guide works through those places layer by layer, with commands that actually run and numbers that have sources.
What does "my site is slow" actually mean? Find the bottleneck first
WordPress slowness almost always belongs to one of two families, and their cures have nothing to do with each other. Server time runs from the moment the request arrives until the first byte of HTML goes out: this is where PHP runs and MySQL queries happen, its metric is TTFB (time to first byte), and its cure is caching and a lighter database. Browser time runs from the moment the HTML arrives until the page is visible: this is the territory of CSS, JavaScript, fonts and images. One command tells the two apart:
curl -o /dev/null -s -w \
"dns:%{time_namelookup} tcp:%{time_connect} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n" \
https://example.com/
dns:0.028 tcp:0.061 tls:0.134 ttfb:0.812 total:0.874
Of the 812 milliseconds of TTFB in this example, 134 ms went on DNS, TCP and TLS, and the remaining 680 ms or so were the server building the page. So the problem lies with PHP and the database, not the network. Google's metrics draw the same line: LCP under 2.5 seconds, INP under 200 milliseconds and CLS under 0.1, measured at the 75th percentile of real users, not in one lucky test.
Layer zero: the infrastructure WordPress runs on
No caching plugin can make up for a slow disk or a server on the other side of the world. Before you install anything, check three things:
- NVMe storage — On every visit, WordPress reads dozens of PHP files and sends several MySQL queries. NVMe removes the SATA bus bottleneck and lowers the latency of every read. For a single visit the difference is limited; its real effect shows when several visitors are queuing for the disk at the same time. The exact difference is explained in our guide to KVM and NVMe.
- An up-to-date PHP version — In Kinsta's benchmarks, standard WordPress served about 139 requests per second on PHP 7.4 and about 148 on PHP 8.5: close to 7 percent, not a multiple. The same test on WooCommerce, however, goes from 44 to 71, and almost all of that gain belongs to 8.5, because WooCommerce stays at roughly 53 to 55 on 8.2 through 8.4. Heavier code leaves more room for improvement.
- A server close to your visitors — For a user in Iran, ping to a server in Europe is usually 80 to 150 milliseconds, and to a server inside the country under 30. Because every page involves dozens of round trips, that gap adds up to several hundred milliseconds of real load time. The same rule holds wherever your audience is: host near them. For this reason, Mehran Host's cloud servers are offered in a datacenter inside Iran, on NVMe storage.
Check your current PHP version right now:
php -v
# PHP 7.4.33 (cli) (built: Nov 4 2022 10:32:56) ( NTS )
# Ubuntu 24.04's official repositories stop at 8.3; for 8.4:
sudo add-apt-repository ppa:ondrej/php && sudo apt update
sudo apt install php8.4-fpm php8.4-mysql php8.4-curl php8.4-gd \
php8.4-mbstring php8.4-xml php8.4-zip php8.4-imagick
One caveat: this command reports the version of php-cli, which is not necessarily the version your site runs on. The real PHP-FPM version is under Tools → Site Health → Info in the WordPress dashboard. Which version should you target? PHP 7.4 and 8.1 no longer receive patches, 8.2 reaches end of life in December 2026, and 8.3 only gets security fixes; active support is on 8.4 and 8.5. pm.max_children matters too: set it too low and requests queue up at peak hours, set it too high and you run out of RAM.
WordPress caching: the biggest single leap when you speed up WordPress
The idea behind a page cache is simple. WordPress runs PHP and queries the database for every page, but why rebuild a page from scratch each time when it is the same for everyone? A WordPress page cache builds the finished HTML once and serves that copy afterwards: no PHP, no database. It is the only change that brings TTFB down to a few dozen milliseconds.
The easy way: a caching plugin
If you don't have root access, install WP Super Cache (simple) or W3 Total Cache (more features, but more complex). In WP Super Cache, all you need to do is select "Caching On" and the "Expert" delivery method (mod_rewrite), so that the HTML is served directly. And never run two page-cache plugins at the same time: the result is half-cached pages.
The professional way: fastcgi_cache in Nginx
On your own VPS, caching at the web-server layer is faster than any plugin, because the request never reaches PHP at all:
fastcgi_cache_path /var/cache/nginx levels=1:2
keys_zone=WORDPRESS:100m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
server {
# ... your existing site settings ...
set $skip_cache 0;
if ($request_method = POST) { set $skip_cache 1; }
if ($query_string != "") { set $skip_cache 1; }
if ($http_cookie ~* "comment_author|wordpress_logged_in|wp-postpass") {
set $skip_cache 1;
}
if ($request_uri ~* "/wp-admin/|/cart/|/checkout/|/my-account/") {
set $skip_cache 1;
}
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 200 60m;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
add_header X-Cache $upstream_cache_status;
}
}
The X-Cache header tells you whether each request was served from the cache (HIT) or not (MISS). Pay attention to fastcgi_cache_key: it has no default, and without it Nginx refuses to start with the error no "fastcgi_cache_key" for "fastcgi_cache". That is the most common reason copied configs fail. If you are new to Nginx, read our guide to setting up a site with Nginx first.
How can I be sure the cache really works?
Don't trust what the plugin's settings page claims; look at the server's response. Request the same URL twice in a row.
curl -sI https://example.com/ | grep -iE "x-cache|cache-control|age"
# x-cache: MISS
curl -sI https://example.com/ | grep -iE "x-cache|cache-control|age"
# x-cache: HIT
# cache-control: max-age=3600
If the second request is also a MISS, something is bypassing the cache: a cookie that every visitor gets (an analytics script, a notification bar), or a condition that is too broad, such as $query_string != "" in the example above, which also leaves campaign links carrying utm_source uncached.
skip_cache conditions above take care of that. After you turn caching on, walk through the checkout process with a test user. A cached cart means one customer sees the previous customer's cart.OPcache: turn on PHP's own cache
Even when a page can't be cached (for a logged-in user, for example), PHP doesn't need to recompile the files every time. OPcache keeps the compiled bytecode in memory and is enabled by default in modern PHP, but its default values, 128 MB of memory and a limit of 10,000 files, are too small for a WordPress site with a theme and twenty plugins. In php.ini:
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=16000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
Then run sudo systemctl reload php8.4-fpm. Copied guides tend to leave out three points. max_accelerated_files is rounded up to the next prime number from a fixed list, so 16000 effectively becomes 16229. interned_strings_buffer is taken out of memory_consumption, not added on top of it. And validate_timestamps=0 is the fastest setting, but it ignores every file change until the service restarts. These few lines are the easiest win in WordPress optimization.
php -r 'print_r(opcache_get_status(false));' shows the OPcache of the CLI, which is separate from the OPcache of the PHP-FPM processes and often looks empty. For the real status, put up a temporary phpinfo() page, then delete it.Persistent object cache: when a page cache isn't enough
A page cache only rescues public pages that look the same for everyone. Every uncacheable visit (a logged-in user, the shopping cart, the dashboard, admin-ajax) still sends dozens of repeated queries, and an object cache keeps their results in memory. WordPress's default object cache is "non-persistent" and is cleared at the end of every request; a persistent cache keeps the same data in Redis or Memcached. Since WordPress 6.1, Site Health also reports when you don't have one, but only as a recommendation, and only above thresholds built into core.
sudo apt install redis-server php8.4-redis
sudo systemctl enable --now redis-server
# Then install and activate the Redis Object Cache plugin, and in wp-config.php:
define('WP_REDIS_HOST', '127.0.0.1');
define('WP_CACHE_KEY_SALT', 'example.com:');
redis-cli info stats | grep keyspace
# keyspace_hits:184320
# keyspace_misses:9117
Two honest caveats. First, it needs a VPS and is not something shared hosting offers; the full path is in our guide to WordPress on a VPS. Second, on a blog whose pages are all page-cached it makes no noticeable difference. It pays off for WooCommerce stores and membership sites, where checkout is uncacheable by nature.
Images: the heaviest passenger on every page
On a typical WordPress page, images make up more than half of the download size. Four practical steps:
- WebP format — In its official WebP documentation, Google says lossy WebP images are 25 to 34 percent smaller than JPEG at equivalent quality, and lossless WebP is 26 percent smaller than PNG. Plugins such as EWWW or Imagify convert your existing images in bulk; on your own server, one line does it:
cwebp -q 82 photo.jpg -o photo.webp. - The right dimensions — Don't upload a 4,000-pixel image for an 800-pixel box. WordPress generates intermediate sizes, but your theme has to use them.
- Lazy loading — Since version 5.5, WordPress adds
loading="lazy"by default; just make sure no plugin or theme has broken it. - Declared width and height — An img tag without
widthandheightmakes the page jump when the image arrives, which is exactly what CLS penalizes. WordPress also addsloading="lazy"only to images that have both attributes.
Since WordPress 6.3, core gives the image most likely to be the LCP element the fetchpriority="high" attribute and exempts it from lazy loading. According to the WordPress core team's announcement, this usually improves LCP by 5 to 10 percent. You don't need to do anything by hand; just watch out for "advanced lazy load" plugins that rewrite this logic.
<link rel="preload" as="image"> for it.CSS and JavaScript: what holds back the first paint
The browser draws nothing on screen until it has downloaded and processed all the CSS and every script that lacks defer or async. These are called render-blocking resources, and in WordPress they usually come from one place: plugins that load their own files on every page, even where they have nothing to do. Count them first:
curl -s https://example.com/ | grep -oE '<(link[^>]*stylesheet|script[^>]*src)[^>]*>' | wc -l
# 34
Thirty-four files is a lot for the home page of a content site. A contact-form plugin's files are only needed on the contact page; remove them from every other page with wp_dequeue_style() and wp_dequeue_script() in a child theme. Every script that isn't needed for the first render should get defer, and fonts should load with font-display: swap, otherwise the browser hides the text until the font arrives.
Retire one old piece of advice as well: "merge all your CSS and JS into one file". That advice belonged to HTTP/1.1, when the browser could only open a few connections at a time. On HTTP/2 and HTTP/3, several small files are multiplexed over a single connection, and forced merging only defeats the cache: updating one plugin invalidates the whole file for everyone.
Plugin diet and database spring cleaning
The cheapest way to speed up WordPress is not to add things but to remove what you don't need.
Fewer plugins, lighter site
Every active plugin means more PHP and often a few extra CSS and JS files. A rule of thumb: once you pass 20 active plugins, it's time for a review. Use the free Query Monitor plugin to see which plugins run the slowest queries; sliders, built-in statistics plugins and heavy page builders are the usual suspects.
The autoload trap in the wp_options table
Before building any page, WordPress reads every "autoloaded" row of wp_options in one go. Badly written plugins store large arrays there, and that volume becomes a fixed tax on every request. WordPress 6.6 added a Site Health check for exactly this, with a warning threshold of 800,000 bytes. Check your own size:
wp option list --autoload=on --format=total_bytes
# 2418733
wp db query "SELECT option_name, ROUND(LENGTH(option_value)/1024) AS kb
FROM wp_options WHERE autoload IN ('yes','on','auto','auto-on')
ORDER BY LENGTH(option_value) DESC LIMIT 5"
# option_name kb
# oldplugin_scan_results 912
# _transient_feed_ab12cd34 301
# theme_mods_mytheme 77
The first row says it all: the scan results of a plugin deleted years ago are still read on every visit. After taking a backup, delete rows like that or switch them to not autoload. Don't be surprised if the WP-CLI figure doesn't match Site Health: the --autoload=on filter only counts on and yes and misses auto and auto-on, while the query above catches all four.
Sweep the database
Expired transients and endless post revisions bloat the tables. The WP-Optimize plugin clears out revisions, auto-drafts, spam comments and dead transients. With WP-CLI the same job takes two lines; to stop revisions piling up again, set a cap:
wp transient delete --expired
wp db optimize
# wp-config.php
define('WP_POST_REVISIONS', 5);
Two hidden consumers: Heartbeat and wp-cron
Heartbeat sends a request to admin-ajax.php every 15 seconds in the editor and every 60 seconds in the dashboard. For a single writer that doesn't matter; for dozens of admins at once it is serious PHP traffic. wp-cron isn't a real cron either: it piggybacks scheduled jobs on visitors' page loads. The right approach is the system cron:
# wp-config.php
define('DISABLE_WP_CRON', true);
# crontab -e
*/5 * * * * cd /var/www/example.com && /usr/bin/php8.4 wp-cron.php >/dev/null 2>&1
We explain the crontab format and its common traps in our Linux cron job guide.
wp db export backup.sql or an export from phpMyAdmin. Thirty seconds of caution saves hours of regret, and if you don't have regular backups, put our server backup guide into practice today.Visitors in Iran: the traps generic speed guides miss
Most WordPress speed articles assume the user and the server sit on the same trouble-free internet. For a site whose audience is in Iran, that assumption is wrong, and it makes two serious differences. Traffic between Iran and the rest of the world has to cross the country's international gateway, and some foreign services are blocked by Iran's internet filtering or reachable only intermittently.
First, dependence on foreign hosts. Every Google font, Gravatar avatar and JavaScript library loaded from a foreign CDN means a fresh DNS lookup and a new TCP and TLS connection over the international link. Even when those hosts are reachable they add hundreds of milliseconds, and when they aren't, the browser waits until the request times out. The fix is to bring all of this onto your own domain: serve fonts locally with @font-face, turn off Gravatar, and ship the libraries with your theme.
Second, the network path. Even a perfectly optimized site pays a fixed round-trip cost if its server is outside the country, and at peak hours the fluctuation in that cost is worse than the cost itself. You have two options: move the origin into Iran, or at least serve static files from a content delivery network with a node in Iran. That won't rescue dynamic HTML, but it delivers images, CSS and JS from the nearest node.
Measure: without numbers, optimization is guesswork
Measure your WordPress site's speed before and after every change; otherwise you won't know which fix worked. Three tools are enough: PageSpeed Insights for Core Web Vitals based on real-user data (it stays empty for low-traffic sites), GTmetrix for the loading waterfall, and curl for pure TTFB:
for i in 1 2 3; do
curl -o /dev/null -s -w "%{time_starttransfer}\n" https://example.com/
done
# 0.812
# 0.104
# 0.098
This means the first request warmed the cache and the next two were served from it; if all three were large, the page cache isn't working. The table below takes you from symptom to cause:
| Symptom | Likely cause | What to do |
|---|---|---|
| High TTFB on every page, even on repeat requests | The page cache is off or being bypassed | Check the cache header with curl -sI; review cookies and query strings |
| TTFB rises only at peak hours | Too few PHP-FPM processes, or not enough RAM | Review pm.max_children and memory usage |
| Slow dashboard, fast site | Too much autoloaded data, or Heartbeat | Measure the autoload size and delete orphaned rows |
| Good TTFB, but the page appears late | Render-blocking resources or fonts | Defer non-essential scripts and load fonts with swap |
| The layout jumps while the page loads | No width and height on images | Declare the dimensions in the HTML to fix CLS |
| Fast for you, slow for your users | External dependencies or geographic distance | Host external resources locally, or move static files to a CDN |
We won't give exact figures for "after each layer", but the order of magnitude of each effect is consistent. A page cache takes TTFB from several hundred milliseconds down to a few dozen; moving the server closer removes 80 to 150 milliseconds from every round trip; a PHP upgrade is worth a few percent, not a multiple; and work on images and scripts does nothing for TTFB but does move LCP. So: infrastructure and caching first, then images, then the details.
Mehran Host tools and services for WordPress speed
To put numbers on where you stand today, the website speed and health test is free and installs nothing. It checks DNS response time, detects your hosting provider and CDN, takes three TTFB samples, and checks the protocol version (HTTP/2 and HTTP/3), compression of HTML, CSS and JS, browser cache headers, image format and size, lazy loading, render-blocking files and exposure of the origin server's IP. Instead of a single score, it gives you a list sorted by the impact of each fix. One honest caveat: the requests go out from our own server, and that server is not in Iran, so treat the TTFB in this report as a baseline for your own before-and-after comparison, not as what a visitor in Iran experiences.
Three services also map onto the layers above: Iran web hosting, which is shared hosting on cPanel or DirectAdmin where day-to-day jobs (databases, file manager, cron jobs, free SSL and backups) are handled from the control panel; an hourly-billed cloud server on KVM and NVMe in a datacenter inside Iran, for testing a staging copy for a few hours; and an Iran CDN with edge nodes in Iran and Europe, which you turn on with a switch next to the A record of a domain whose DNS is hosted with us; the first 1 TB of CDN traffic is free.
Next step
Speeding up WordPress is not a one-day job, but the order is clear. If you have time for four things today, make it these four:
- Record your current numbers with curl and PageSpeed Insights.
- Update PHP and tune OPcache with higher limits.
- Enable one (and only one) page cache, and use the response header to confirm you get a HIT.
- Measure the autoload size and, after taking a backup, delete the rows left behind by removed plugins.
To practice all of this before touching your live site, an hourly-billed cloud server is exactly the right tool: spin it up for a few hours, practice, and then carry the result over to the real site.
Frequently asked questions
What is the fastest way to speed up a WordPress site?
Turning on a page cache gives the biggest improvement for the least effort. A page cache stores the finished HTML and answers later requests without running PHP and without querying the database, which usually brings server response time down to a few dozen milliseconds. On shared hosting, a caching plugin is enough; on a VPS, caching at the web-server layer is faster.
How many WordPress plugins is too many?
There is no fixed number. The right measure is not the count but the work each plugin does on every request, and ten clean plugins are lighter than three badly written ones. Even so, once you pass twenty active plugins it is time for a review: use the Query Monitor plugin to see which one runs the slowest queries, and start there.
How much does upgrading PHP speed up WordPress?
A PHP upgrade usually brings a few percent, not a multiple. In published benchmarks, standard WordPress serves about 7 percent more requests per second when going from PHP 7.4 to 8.5. On WooCommerce the gap is bigger, from 44 to 71 requests per second, but almost all of that jump is specific to 8.5, while versions 8.2 to 8.4 stay at about 53 to 55. Still, don't put the upgrade off: old versions no longer receive security patches.
Why is my WordPress site slow for visitors in Iran but fast in tests?
Usually for two reasons: network distance and dependence on foreign hosts. A server outside Iran pays the fixed cost of an international round trip on every request. On top of that, every font or avatar loaded from a domain outside your site needs a fresh connection, and if that domain is unreachable from Iran, the browser waits until the request times out.
Does every WordPress site need a Redis object cache?
No. An object cache is worth it when a large share of visits can't be cached: a WooCommerce store, a membership site, a forum, or anywhere with many logged-in users. For a blog where almost every page is page-cached, it makes no noticeable difference. It also needs a VPS with Redis or Memcached installed and is usually not available on shared hosting. Fix the page cache first, then come to this.