In this guide: 9 chapters
Most slow WordPress sites have two or three real problems. The trick is finding them before you start changing settings. A caching plugin cannot fix a slow database, and a faster server cannot fix a 4 MB hero image.
This guide works in the same order we use on client sites. You measure first, find the bottleneck, then fix the server, caching, images, scripts and database in that order. Each chapter ends with a quick test so you know the fix worked. Skip the chapters that your test results rule out.
Measure first: lab data, field data and the numbers that matter
Start with numbers. A good test tells you which of the next eight chapters matter for your site, so you spend your time on the fixes that move the needle.
Lab data and field data are different
Lab data comes from one test run in a controlled setting. The 0 to 100 score in PageSpeed Insights is a lab test, and so are GTmetrix and WebPageTest reports. They are great for finding problems and checking a fix the same day.
Field data comes from real Chrome users who visited your site over the last 28 days. Google collects it in the Chrome UX Report (CrUX) and uses it for Search. PageSpeed Insights shows it at the top of the report, above the lab score, when your site has enough traffic.
Google rates a page by its 75th percentile. That means at least 3 out of 4 visits must meet the target for the page to pass. A fast test on your office Wi-Fi does not count for much if visitors on phones wait twice as long.
The targets
| Metric | What it measures | Good | Needs work | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | How fast the main content shows up | 2.5 s or less | 2.5 to 4 s | Over 4 s |
| INP (Interaction to Next Paint) | How fast the page reacts to taps, clicks and keys | 200 ms or less | 200 to 500 ms | Over 500 ms |
| CLS (Cumulative Layout Shift) | How much the layout jumps while loading | 0.1 or less | 0.1 to 0.25 | Over 0.25 |
| TTFB (Time to First Byte) | How fast the server starts sending the page | 0.8 s or less | 0.8 to 1.8 s | Over 1.8 s |
LCP, INP and CLS are the three Core Web Vitals. TTFB is a supporting number. It is not a Core Web Vital, but a slow first byte delays everything after it, so we track it too.
Take your before numbers
- Pick 3 to 5 key pages: the home page, your top landing page, a typical blog post, a product page and the first step of checkout if you sell online.
- Run each page through PageSpeed Insights on mobile. Write down the field data at the top and the lab numbers below it.
- If a page has no field data (common on low-traffic pages), run the lab test 3 times and keep the middle result.
- Open Google Search Console and check the Core Web Vitals report. It groups similar URLs, so one bad template shows up as many failing pages.
- Save the numbers in a sheet with the date. You will compare against them after every chapter.
You can also check TTFB from a terminal. Run it 3 times, because the first request may miss the cache.
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}s Total: %{time_total}s\n" https://example.com/You have LCP, INP, CLS and TTFB for each key page, on mobile, saved with today’s date. Our free speed test shows lab and field data side by side if you want a second opinion.
Find the bottleneck before you fix anything
Each failing number points to a short list of causes. Use this table to decide which chapters to work on first.
| What you see | Most likely cause | Start with |
|---|---|---|
| TTFB over 0.8 s | No page cache, slow hosting, heavy plugins or a slow database | Chapters 3, 4 and 7 |
| Slow LCP with a fast TTFB | Large hero image, image found late, render-blocking CSS or fonts | Chapters 5 and 6 |
| Poor INP | Heavy JavaScript, long tasks, third-party scripts, a very large page | Chapter 6 |
| CLS over 0.1 | Images with no size, fonts swapping, ads, embeds or banners that load late | Chapter 5 |
| Fast near your office, slow far away | No CDN, or the CDN only caches static files | Chapter 4 |
| Slow admin, cart or logged-in pages | Database, missing object cache, too few PHP workers | Chapters 3, 7 and 8 |
Break LCP into its four parts
A slow LCP is always one or more of four delays. PageSpeed Insights shows this split in its LCP breakdown section.
- Time to first byte. The browser is still waiting for the server. Fix the server and caching.
- Load delay. The browser finds the LCP image late, often because it is a CSS background, lazy-loaded or added by JavaScript.
- Load time. The image file is too big or comes from a slow server.
- Render delay. The image is ready, but CSS, fonts or scripts are still blocking the page from painting.
Fix the biggest slice first. If TTFB takes 1.4 seconds of a 3 second LCP, image work alone will not get you under 2.5 seconds.
Read a waterfall
Open Chrome DevTools, go to the Network tab, check Disable cache and reload. Each row is one file. Look at what loads before your main image and how long the first row (the HTML) takes. WebPageTest shows the same view with a filmstrip, which helps when you are explaining a problem to someone else.
You can name the one or two causes behind each failing number, and you know which chapters you can skip.
Fix the server: PHP, CPU, compression and location
Every request that misses the cache runs PHP and asks the database for data. Caching hides a slow server from some visitors, but logged-in users, shoppers with a cart, search pages and the admin always hit it directly.
Run a current PHP version with OPcache
WordPress recommends PHP 8.3 or newer. Each new PHP version runs WordPress faster than the last, and old versions stop getting security fixes. Check your version under Tools > Site Health > Info > Server.
- Copy the site to staging and switch staging to the new PHP version first.
- Click through the home page, forms, checkout and the admin. Then check the PHP error log for new warnings.
- Update any plugin or theme that throws errors, then switch the live site.
OPcache keeps compiled PHP code in memory, so the server does not have to read and compile every file on every request. Most hosts turn it on. If you manage your own server, these settings suit a typical WordPress site:
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.revalidate_freq=60The PHP version you see in a terminal (php -v) can differ from the one your website runs. Trust Site Health, or create a phpinfo() page, check it and delete it right away.
Give PHP enough CPU and workers
A PHP worker handles one uncached request at a time. When every worker is busy, new visitors wait in line. Signs of a worker or CPU problem are TTFB that spikes at busy hours, slow admin screens and 503 or 504 errors during sales or email campaigns. Cheap shared plans often sell the same CPU to many sites, so your speed depends on your neighbors.
Turn on HTTP/2 or HTTP/3 and Brotli
HTTP/2 and HTTP/3 let the browser download many files over one connection. Brotli and Gzip shrink text files such as HTML, CSS and JavaScript by 70% or more. Check what your site sends:
curl -sI --http2 -H "Accept-Encoding: br, gzip" https://example.com/ | grep -iE "^HTTP|content-encoding|alt-svc"You want to see HTTP/2 (or an alt-svc header that offers h3) and content-encoding: br or gzip. Cloudflare turns on HTTP/3 and Brotli at its edge for sites that use it.
Host close to your visitors
Distance adds delay to every round trip. Put the server in the region where most of your visitors live, then use a CDN for everyone else. A site for a Maryland law firm should not be served from Singapore.
Every plan runs PHP 8.3 with OPcache on NGINX, with dedicated CPU power and HTTP/3 plus Brotli through Cloudflare. We size PHP workers for each site’s traffic.
Force a cache miss by adding a unique query string, such as https://example.com/?nocache=12345, then run the curl TTFB test from Chapter 1. An uncached page should start arriving in under 0.8 seconds.
Set up caching in layers
Caching saves work so it does not have to be done again. A fast WordPress site uses four layers, and each one helps a different kind of visit.
- 1Browser cacheOn the visitor’s deviceKeeps images, CSS and JavaScript after the first visit, so repeat page views download almost nothing.
- 2Edge cache (CDN)At CDN locations near the visitorServes static files, and with a cache rule full HTML pages, from a server close to the visitor. Absorbs traffic spikes.
- 3Page cacheOn your web serverSaves the finished HTML of each page, so logged-out visitors skip PHP and the database.
- 4Object cache (Redis)In server memoryKeeps the results of repeat database lookups. Helps logged-in users, carts, search and the admin, where page caching cannot.
- DBPHP and the databaseThe slow pathRuns only when every layer above misses. The goal is to reach it as rarely as possible.
A request stops at the first layer that has a fresh copy.
Browser cache: long lifetimes for static files
WordPress adds a version to CSS and JavaScript file names (style.css?ver=6.8), so a new version gets a new URL. That makes it safe to tell browsers to keep these files for a year. On Apache, add this to .htaccess:
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/webp "access plus 1 year"
ExpiresByType image/avif "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
ExpiresByType text/css "access plus 1 year"
ExpiresByType application/javascript "access plus 1 year"
</IfModule>Page cache: one plugin or one server cache
Use one page cache only. Two caching plugins, or a plugin on top of a host cache that does the same job, fight each other and cause stale or broken pages. If you remove a caching plugin, check that it did not leave wp-content/advanced-cache.php behind or WP_CACHE set in wp-config.php. We find leftover files like these on sites moving from other hosts all the time, and they quietly block the new cache.
Never cache pages that change per visitor. Every page cache and CDN rule should skip these:
- Paths:
/wp-admin/,/wp-login.php,/cart/,/checkout/,/my-account/and any membership or account pages. - Cookies:
wordpress_logged_in_,wp-postpass_,comment_author_,woocommerce_items_in_cart,woocommerce_cart_hashandwp_woocommerce_session_. - Requests with a
POSTmethod and responses that sendCache-Control: no-storeorprivate.
Object cache: Redis
Redis keeps database results in memory. It needs a Redis server from your host, plus a plugin that connects WordPress to it. With WP-CLI:
wp plugin install redis-cache --activate
wp redis enable
wp redis statusIf several sites share one Redis server, give each site its own prefix in wp-config.php so their data never mixes:
define( 'WP_REDIS_PREFIX', 'example_com_' );Edge cache: CDN for files, and for HTML when it is safe
A CDN copies your files to servers around the world. Most CDNs cache images, CSS and JavaScript out of the box. Caching the HTML page itself at the edge cuts TTFB for visitors everywhere, but it needs a rule that skips the paths and cookies listed above. In Cloudflare, create a Cache Rule set to Eligible for cache with an expression like this:
(http.host eq "example.com"
and not starts_with(http.request.uri.path, "/wp-admin")
and not starts_with(http.request.uri.path, "/wp-login.php")
and not starts_with(http.request.uri.path, "/cart")
and not starts_with(http.request.uri.path, "/checkout")
and not starts_with(http.request.uri.path, "/my-account")
and not http.cookie contains "wordpress_logged_in"
and not http.cookie contains "woocommerce_items_in_cart"
and not http.cookie contains "wp_woocommerce_session")Set Edge TTL to use the cache-control header from your server (the API calls this respect_origin). We learned this one the hard way: with a forced edge lifetime, a security challenge page sent with no-store got cached and shown to every visitor in one region. Respecting the origin keeps pages marked no-store out of the cache.
Every plan includes a server page cache and Redis object cache. Our Cloudflare CDN Basic tier and up adds full-page edge caching, with carts, checkout and logged-in visitors kept out of the cache automatically.
Request a page twice and read the headers:
curl -sI https://example.com/ | grep -iE "cf-cache-status|x-cache|age|cache-control"
The second request should show HIT. Your cart, checkout and admin pages should show BYPASS, DYNAMIC or MISS every time.
Fix images and fonts
Images are usually the heaviest part of a page and often the LCP element. Fonts are small, but they can hide your text or make the layout jump. Both are quick wins.
Images
- Resize to the size shown. A 4000 px photo in a 1200 px slot wastes most of its bytes. When you add images through the WordPress editor, WordPress creates smaller copies and a
srcsetso phones get a smaller file. Images added as page builder backgrounds or in custom HTML often skip this. - Use WebP or AVIF. Both are much smaller than JPEG and PNG at the same quality. WordPress has supported WebP since version 5.8 and AVIF since 6.5, as long as your server’s image library supports them. As a rough guide, keep a hero image under 200 KB.
- Set width and height. When the browser knows an image’s size, it saves the space and the layout does not jump. WordPress adds these to editor images. Check theme and builder images by hand.
- Lazy-load images below the fold. WordPress adds
loading="lazy"for you and skips the first large image. Never lazy-load the LCP image. It delays the most important picture on the page. - Give the LCP image priority. WordPress 6.3 and newer adds
fetchpriority="high"to the image it thinks is the LCP. Sliders and page builders can fool it, so check the result in the page source.
A hero image done right looks like this:
<img src="/wp-content/uploads/hero-1200.webp"
srcset="/wp-content/uploads/hero-800.webp 800w,
/wp-content/uploads/hero-1200.webp 1200w"
sizes="(max-width: 800px) 100vw, 1200px"
width="1200" height="675"
alt="Our team at work"
fetchpriority="high">If the hero is a CSS background image, the browser cannot find it until the CSS loads. Preload it in the page head, or switch it to a normal image:
<link rel="preload" as="image" href="/wp-content/uploads/hero-1200.webp" fetchpriority="high">Hero sliders are a common LCP problem. They load several large images and often wait for JavaScript before showing the first one. A single static hero is almost always faster.
Fonts
- Host fonts on your own server. Download them, or turn on the “load fonts locally” option in your theme or page builder. This removes a connection to another company’s server on every first visit.
- Load less. Use one or two families and only the weights you use. Use WOFF2 files only.
- Show text right away.
font-display: swapshows a fallback font while the web font loads. - Preload the main font file. Preload the one font used above the fold, usually your body or heading weight.
@font-face {
font-family: "DM Sans";
src: url("/wp-content/uploads/fonts/dm-sans-500.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}<link rel="preload" href="/wp-content/uploads/fonts/dm-sans-500.woff2" as="font" type="font/woff2" crossorigin>The crossorigin attribute is required on font preloads, even for your own domain. Without it, the browser downloads the font twice.
On Cloudflare CDN Basic and up, our plugin hosts fonts locally and handles lazy loading. Premium and up adds cloud image optimization that converts and resizes images for you.
In PageSpeed Insights, the image delivery and font checks come back clean, the LCP element is your hero image, and CLS is 0.1 or less.
Tame CSS and JavaScript
The browser must download and read CSS before it paints the page, and JavaScript keeps the browser busy while it runs. Long tasks (anything over 50 milliseconds) make the page slow to react, which is what INP measures.
CSS
- Remove unused CSS. Themes and page builders ship styles for every widget they offer. Turn off features you do not use, and use your optimizer’s “remove unused CSS” option if it has one.
- Inline critical CSS. Put the small amount of CSS needed for the top of the page in the HTML, then load the rest without blocking. Most optimization plugins can do this.
- Minify CSS, JavaScript and HTML. Minifying strips spaces and comments. The gain is small on top of Brotli, but it is free.
Defer and delay JavaScript
Defer downloads a script in the background and runs it after the HTML is read, in order. It is safe for most scripts. Delay waits until the visitor scrolls, taps or presses a key. It suits chat widgets, heatmaps, extra analytics and popups. It does not suit menus, sliders or anything visible at the top of the page.
If you write your own theme code, WordPress 6.3 and newer can add defer for you:
wp_enqueue_script(
'my-script',
get_stylesheet_directory_uri() . '/js/my-script.js',
array(),
'1.0',
array( 'strategy' => 'defer', 'in_footer' => true )
);When we rolled our optimizer out to more than 200 sites, three kinds of script broke most often:
1) Settings that WordPress prints before a script (handles ending in -js-extra or -js-before) were deferred while the script that needs them ran right away. 2) Code waiting on jQuery’s ready event ran before later deferred files loaded. 3) Old themes calling $(window).load() broke once jQuery Migrate was removed.
Test menus, forms, sliders, popups and checkout after every change to script loading.
Cut third-party scripts
Chat widgets, tag managers, heatmaps, ad pixels and embedded videos often cost more than WordPress itself. List every outside script on your key pages, ask who uses each one, and remove the ones nobody checks. For the rest:
- Delay them until the first interaction.
- Replace YouTube and map embeds with a click-to-load image (often called a facade).
- Load scripts on the pages that need them only, such as a booking widget on the booking page.
- Add
preconnectfor an outside domain you must use early, so the connection is ready when it is needed:
<link rel="preconnect" href="https://www.googletagmanager.com">Fix INP and page size
Poor INP means the page takes too long to react. Record a slow interaction in the DevTools Performance panel and look for long tasks in red. Common causes are mega menus, sliders, analytics that run on every click and very large pages.
Lighthouse warns when a page has more than about 800 HTML elements and flags it as excessive at about 1,400. Page builders add a lot of wrappers. In Elementor, use Flexbox containers instead of nested sections and columns, keep nesting shallow and turn on the options under Elementor > Settings > Performance. Elements you hide on mobile still load, so build separate mobile sections only when you have to.
Our plugin defers and delays scripts based on your CDN tier, keeps WordPress settings scripts in the right order and runs a safer profile on stores, so checkout and tracking keep working.
Lab Total Blocking Time is under 200 ms, the browser console shows no new errors, and you have clicked through the menu, forms, popups and checkout on a phone.
Clean the database and audit plugins
Plugins leave data behind, revisions pile up and scheduled tasks fail quietly. A heavy database slows every uncached page and the whole admin. These steps use WP-CLI. Back up the database before you run any of them.
wp db export before-cleanup.sqlTrim autoloaded options
Autoloaded options load on every single request, cached or not. Site Health in WordPress 6.6 and newer warns when they add up to more than 800 KB. Check your total:
wp db query "SELECT ROUND(SUM(LENGTH(option_value))/1024) AS autoload_kb FROM $(wp db prefix)options WHERE autoload IN ('yes','on','auto','auto-on');"Then list the biggest ones:
wp db query "SELECT option_name, ROUND(LENGTH(option_value)/1024) AS kb FROM $(wp db prefix)options WHERE autoload IN ('yes','on','auto','auto-on') ORDER BY LENGTH(option_value) DESC LIMIT 20;"Options named after plugins you deleted long ago are safe to remove with wp option delete option_name. If you are not sure what an option does, leave it.
Limit revisions and clear expired transients
define( 'WP_POST_REVISIONS', 10 );# Delete expired transients
wp transient delete --expired
# Optimize the tables
wp db optimizeCheck Action Scheduler and WP-Cron
WooCommerce and many other plugins queue background jobs in Action Scheduler. Completed jobs are kept for 31 days by default. Thousands of failed or past-due jobs usually mean scheduled tasks are not running.
wp db query "SELECT status, COUNT(*) FROM $(wp db prefix)actionscheduler_actions GROUP BY status;"To keep a busy store’s table smaller, shorten how long finished jobs are kept:
add_filter( 'action_scheduler_retention_period', function () {
return 7 * DAY_IN_SECONDS;
} );WP-Cron runs scheduled tasks during visitor page loads. On a busy site that slows random visitors, and on a quiet site tasks run late. Turn it off and run it from a real server schedule instead:
define( 'DISABLE_WP_CRON', true );*/5 * * * * cd /home/user/public_html && wp cron event run --due-now > /dev/null 2>&1Audit plugins by weight
There is no magic plugin count. One heavy plugin can cost more than twenty light ones. Install the free Query Monitor plugin, load a slow page while logged in and check:
- Queries by Component: which plugin runs the most database queries and the slowest ones.
- HTTP API Calls: plugins that call outside services before the page can finish.
- Hooks and actions: plugins that load on pages where they are not used.
Remove plugins nobody uses, replace heavy ones with lighter options and test on staging. The Health Check & Troubleshooting plugin can turn plugins off for your session only, so you can test without affecting visitors.
Our Speed Optimization service includes this cleanup, a plugin audit and moving WP-Cron to a server schedule. Our plugin auditing page covers what we look for.
Autoloaded options are under 800 KB, Action Scheduler shows no pile of failed or past-due jobs, and Query Monitor shows no single plugin taking more than a few hundred milliseconds on a normal page.
Speed up WooCommerce stores
Stores are harder to speed up because the pages that matter most, the cart and checkout, can never be cached. Server speed, Redis and lean scripts matter more for a store than for a blog.
Cart fragments
Cart fragments is the script that keeps the mini-cart count up to date. Each page view sends an uncached request to ?wc-ajax=get_refreshed_fragments. Since WooCommerce 7.8, the script only loads where the Cart widget is shown, but many themes still put that widget in the header of every page.
- Open DevTools > Network on a product page and filter for
wc-ajax. Ifget_refreshed_fragmentsruns on every page, your theme loads it everywhere. - Switch to the Mini-Cart block if your theme supports it, or limit the header cart to visitors who have something in their cart.
- Retest add to cart, the cart count and checkout after the change.
Turn on High-Performance Order Storage (HPOS)
HPOS stores orders in their own database tables instead of the shared posts tables, which makes order screens and checkout faster on busy stores. It is the default for new stores since WooCommerce 8.2. Older stores have to switch on their own:
- Go to WooCommerce > Settings > Advanced > Features.
- Check that every order-related plugin is marked compatible. WooCommerce lists the ones that are not.
- Turn on compatibility mode so both storage types stay in sync, and let it finish.
- Switch to High-performance order storage, test orders, refunds and reports, then turn compatibility mode off after a few days.
Keep the store dynamic in the right places
- Exclude cart, checkout and account pages, plus the WooCommerce cookies from Chapter 4, from every cache.
- Use Redis. Logged-in customers and carts get most of their speed from the object cache.
- Show a sensible number of products per page. 24 is plenty for most shops.
- Use the Action Scheduler check from Chapter 7. Stores pile up the most background jobs.
- Run a safer script profile on stores. Payment, tax and tracking scripts break more easily when deferred.
Stores run a safe optimization profile by default. Cart, checkout and account pages are never cached, and every plan includes Redis.
As a logged-out visitor, add a product to the cart and confirm the count updates on the next page. Then check that cart and checkout are never served from cache:
curl -sI https://example.com/checkout/ | grep -i cf-cache-status should show DYNAMIC or BYPASS.
Keep it fast after the work is done
Sites slow down over time. New plugins, new tracking scripts, bigger images and theme updates add weight a little at a time. A short routine keeps the gains.
- Monthly: check the Core Web Vitals report in Search Console and the field data in PageSpeed Insights. Field data covers 28 days, so changes show up slowly.
- After every update or new plugin: run the lab test on your key pages the same day and compare with your saved numbers.
- Before adding a script: ask who will use it and what it costs. Agree on a budget, for example LCP under 2.5 seconds on mobile for every key page.
- Keep a change log. A one-line note with the date for each change makes it easy to trace a slowdown later.
- Use staging for big changes. Test theme switches, builder upgrades and new plugins on a copy first.
- Watch uptime and response time. A monitor that checks your site every few minutes catches slowdowns and outages before visitors report them. Our uptime monitor page explains how ours works.
Every plan includes Core Web Vitals tracking in your dashboard, weekly plugin and core updates, uptime monitoring and ongoing re-tuning by our team.
You have a monthly reminder, a saved baseline and a change log. When a number gets worse, you can match it to a date and a change.
Written and reviewed by Nicky Stafford, founder of WP Farm. WP Farm has run managed WordPress hosting since 2008. Every step in this guide is one our team uses on the sites we host, including a rollout of our own caching and optimization plugin to more than 200 live sites in 2026.