Skip to content

Performance Optimization

Every 100ms of load time improvement increases conversion rates by 1-2%. For medical practice websites, a slow site means lost patient appointments. Google also uses Core Web Vitals as a ranking signal — faster sites rank higher.

Lighthouse performance score comparison: WordPress at 45 out of 100 versus Cloudflare Pages at 95 out of 100, a gain of 50 points

WordPress reality: Most WordPress sites score 30-60 on Lighthouse mobile. Plugin bloat, render-blocking CSS, unoptimized images, and database queries drag performance down.

Cloudflare Pages target: 90+ mobile, 95+ desktop. No server-side rendering, no database, no PHP overhead. Just static files served from 300+ edge locations.


Core Web Vitals are Google’s metrics for measuring real-world user experience. They directly affect search rankings.

MetricWhat It MeasuresGoodNeeds WorkPoor
LCP (Largest Contentful Paint)How fast the main content loads< 2.5s2.5-4.0s> 4.0s
INP (Interaction to Next Paint)How fast the page responds to clicks/taps< 200ms200-500ms> 500ms
CLS (Cumulative Layout Shift)How much the page layout jumps around< 0.10.1-0.25> 0.25

What triggers LCP: The largest image, video, or text block visible in the viewport when the page first loads. For medical practice sites, this is typically the hero image or hero heading.

ProblemFixImpact
Large hero image (2MB+ JPEG)Convert to WebP, resize to viewport widthHigh -1.5s to -3s
Render-blocking CSSInline critical CSS, defer the restHigh -0.5s to -1s
Render-blocking JSDefer/async all scriptsHigh -0.5s to -1.5s
Slow server response (TTFB)Already solved — CDN edge servingHigh -0.5s to -2s
Web font blocking renderfont-display: swap, preloadMedium -0.3s to -0.5s
Lazy-loading the LCP imageRemove loading="lazy" from hero imageMedium -0.5s to -1s
ProblemFixImpact
Heavy JavaScript on main threadCode-split, defer non-critical JSHigh -100ms to -300ms
Large DOM (3000+ elements)Simplify markupMedium -50ms to -150ms
Third-party scripts (chat, analytics)Delay until user interactionHigh -100ms to -500ms
Long-running event handlersBreak into smaller tasksMedium -50ms to -200ms
ProblemFixImpact
Images without width/heightAlways set width and height attributesHigh -0.1 to -0.3
Web fonts causing text reflowfont-display: swap + match fallback metricsMedium -0.05 to -0.15
Ads/embeds without reserved spaceUse aspect-ratio or fixed containersHigh -0.1 to -0.5
Content injected above the foldReserve space or load below foldMedium -0.1 to -0.3
<!-- WRONG: No dimensions = layout shift when image loads -->
<img src="/images/doctor.webp" alt="Dr. Smith">
<!-- CORRECT: Explicit dimensions prevent layout shift -->
<img src="/images/doctor.webp" width="600" height="400" alt="Dr. Smith">
<!-- ALSO CORRECT: CSS aspect-ratio for responsive images -->
<img src="/images/doctor.webp" style="aspect-ratio: 3/2; width: 100%;" alt="Dr. Smith">
ToolTypeURL
PageSpeed InsightsLab + Fieldhttps://pagespeed.web.dev/
Lighthouse (Chrome DevTools)Lab onlyF12 > Lighthouse tab
Web Vitals ExtensionLab (real-time)Chrome Web Store
Chrome UX Report (CrUX)Field data (real users)developer.chrome.com/docs/crux/
Search ConsoleField datasearch.google.com > Core Web Vitals

Cloudflare provides built-in performance features that require zero code changes. Enable them in the Cloudflare Dashboard.

Auto Minify

Removes whitespace, comments, and unnecessary characters from CSS, JS, and HTML.

Plan: Free | Risk: Low | Impact: 5-15% smaller files

Enable: Speed > Optimization > Content Optimization > Auto Minify

Brotli Compression

15-25% better compression than gzip for text-based assets.

Plan: Free | Risk: None | Impact: Significant for HTML/CSS/JS

Enable: Speed > Optimization > Content Optimization > Brotli

Rocket Loader

Defers loading of all JavaScript until after page renders.

Plan: Free | Risk: Medium | Impact: 0.5-1.5s LCP improvement

Test thoroughly — can break forms and interactive elements.

HTTP/3 and QUIC

UDP-based transport for faster connections, especially on mobile.

Plan: Free | Risk: None | Impact: 10-30% faster mobile

Enable: Network > HTTP/3 (with QUIC)

Early Hints (103)

Sends preload hints before the full response, so browsers start downloading CSS/fonts early.

Plan: Free | Risk: None | Impact: 0.1-0.5s LCP improvement

Enable: Speed > Optimization > Content Optimization > Early Hints

Polish (Image Optimization)

Automatically compresses and converts images to WebP/AVIF.

Plan: Pro ($20/mo) | Risk: None | Impact: 30-70% smaller images

For free tier, use build-time optimization instead.

Terminal window
# Enable Auto Minify
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/minify" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"value":{"css":"on","html":"on","js":"on"}}'
# Enable Brotli
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/brotli" \
-H "Authorization: Bearer {api_token}" \
--data '{"value":"on"}'
# Enable HTTP/3
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/http3" \
-H "Authorization: Bearer {api_token}" \
--data '{"value":"on"}'
# Enable Early Hints
curl -X PATCH "https://api.cloudflare.com/client/v4/zones/{zone_id}/settings/early_hints" \
-H "Authorization: Bearer {api_token}" \
--data '{"value":"on"}'

Images are typically 50-80% of a page’s total weight. Optimizing images is the single highest-impact performance improvement.

Terminal window
npm install sharp glob
scripts/optimize-images.js
import sharp from 'sharp';
import { glob } from 'glob';
import path from 'path';
import fs from 'fs';
const IMAGE_DIR = './public/images';
const QUALITY = 80;
const MAX_WIDTH = 1920;
async function optimizeImages() {
const images = await glob(`${IMAGE_DIR}/**/*.{jpg,jpeg,png,gif}`, { nodir: true });
console.log(`Found ${images.length} images to optimize`);
for (const imagePath of images) {
const ext = path.extname(imagePath);
const webpPath = imagePath.replace(ext, '.webp');
if (fs.existsSync(webpPath)) {
const srcStat = fs.statSync(imagePath);
const webpStat = fs.statSync(webpPath);
if (webpStat.mtimeMs > srcStat.mtimeMs) continue;
}
try {
const metadata = await sharp(imagePath).metadata();
let pipeline = sharp(imagePath);
if (metadata.width > MAX_WIDTH) {
pipeline = pipeline.resize(MAX_WIDTH, null, { withoutEnlargement: true, fit: 'inside' });
}
await pipeline.webp({ quality: QUALITY }).toFile(webpPath);
const originalSize = fs.statSync(imagePath).size;
const webpSize = fs.statSync(webpPath).size;
const savings = ((1 - webpSize / originalSize) * 100).toFixed(1);
console.log(` Done: ${path.basename(imagePath)} → .webp (${savings}% smaller)`);
} catch (err) {
console.error(` Error: ${path.basename(imagePath)} - ${err.message}`);
}
}
}
optimizeImages();

Serve different image sizes for different screen widths:

<picture>
<source type="image/webp"
srcset="/images/hero-480.webp 480w, /images/hero-768.webp 768w,
/images/hero-1200.webp 1200w, /images/hero-1920.webp 1920w"
sizes="100vw">
<img src="/images/hero-1200.jpg"
srcset="/images/hero-480.jpg 480w, /images/hero-768.jpg 768w,
/images/hero-1200.jpg 1200w, /images/hero-1920.jpg 1920w"
sizes="100vw" width="1920" height="1080"
alt="Modern medical practice lobby" fetchpriority="high">
</picture>
<!-- Above the fold (hero): NO lazy loading, high priority -->
<img src="/images/hero.webp" width="1920" height="1080"
alt="Hero image" fetchpriority="high">
<!-- Below the fold: Lazy load -->
<img src="/images/services.webp" width="600" height="400"
alt="Our services" loading="lazy" decoding="async">
<!-- Far below the fold: Lazy load + low priority -->
<img src="/images/testimonials.webp" width="300" height="300"
alt="Patient testimonial" loading="lazy" decoding="async" fetchpriority="low">
---
import { Image } from 'astro:assets';
import heroImage from '../assets/images/hero.jpg';
import doctorPhoto from '../assets/images/doctor.jpg';
---
<!-- Auto: converts to WebP, generates srcset, sets dimensions -->
<Image src={heroImage} alt="Modern medical practice"
widths={[480, 768, 1200, 1920]} sizes="100vw"
loading="eager" fetchpriority="high" />
<!-- Below-fold: lazy loaded by default -->
<Image src={doctorPhoto} alt="Dr. Smith"
widths={[300, 600]} sizes="(max-width: 768px) 100vw, 600px" />
  • All images converted to WebP (build-time)
  • Maximum image width set to 1920px
  • Responsive images with srcset and sizes for hero/large images
  • Hero image has fetchpriority="high" and no loading="lazy"
  • All below-fold images have loading="lazy" and decoding="async"
  • All images have explicit width and height attributes
  • Image quality set to 80 for WebP
  • No images over 200KB after optimization

Inline the minimum CSS needed to render above-the-fold content in the <head>:

<head>
<!-- Critical CSS inlined -->
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; line-height: 1.6; }
.header { background: #fff; padding: 1rem 0; border-bottom: 1px solid #e5e7eb; }
.hero { padding: 4rem 0; background: #f8fafc; }
.hero h1 { font-size: 2.5rem; font-weight: 700; color: #1e293b; }
</style>
<!-- Full stylesheet loaded asynchronously -->
<link rel="preload" href="/css/style.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/css/style.css"></noscript>
</head>
Terminal window
npm install critical
import { generate } from 'critical';
await generate({
base: './public/',
src: 'index.html',
target: { html: 'index.html', css: 'css/critical.css' },
width: 1300,
height: 900,
inline: true
});
Terminal window
npm install purgecss
import { PurgeCSS } from 'purgecss';
import fs from 'fs';
const result = await new PurgeCSS().purge({
content: ['./public/**/*.html'],
css: ['./public/css/**/*.css'],
safelist: {
standard: ['active', 'open', 'visible', 'is-scrolled'],
deep: [/^modal/, /^dropdown/],
greedy: [/^wp-/]
}
});
for (const { file, css } of result) {
fs.writeFileSync(file, css);
}

Typical results:

FileBeforeAfter PurgeCSSReduction
style.css (Divi theme)380KB45KB88%
style.css (Bootstrap)230KB28KB88%
style.css (custom theme)85KB22KB74%
  • Critical CSS extracted and inlined in <head>
  • Non-critical CSS loaded asynchronously
  • Unused CSS removed via PurgeCSS
  • CSS files minified (Cloudflare Auto Minify or build-time)
  • No @import in CSS files (use <link> tags instead)
  • CSS file count minimized (ideally 1-2 files)
  • Total CSS under 50KB after optimization

<!-- BLOCKING (default) - Pauses HTML parsing -->
<script src="/js/app.js"></script>
<!-- DEFER - Downloads in parallel, executes after HTML parsed -->
<script defer src="/js/app.js"></script>
<!-- ASYNC - Downloads in parallel, executes immediately when ready -->
<script async src="/js/analytics.js"></script>
AttributeDownloadsExecutesOrderBest For
(none)Blocks parsingImmediatelyYesAlmost never — avoid
deferIn parallelAfter HTML parsedYesMost scripts
asyncIn parallelWhen readyNoIndependent scripts (analytics)
<script>
const DELAYED_SCRIPTS = [
'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX',
'https://widget.intercom.io/widget/xxxxx',
];
let loaded = false;
function loadDelayedScripts() {
if (loaded) return;
loaded = true;
DELAYED_SCRIPTS.forEach(src => {
const script = document.createElement('script');
script.src = src;
script.async = true;
document.body.appendChild(script);
});
}
// Load on first user interaction
['mouseover', 'touchstart', 'scroll', 'keydown'].forEach(event => {
document.addEventListener(event, loadDelayedScripts, { once: true });
});
// Fallback: load after 5 seconds
setTimeout(loadDelayedScripts, 5000);
</script>

Common Scripts to Remove in WordPress Migrations

Section titled “Common Scripts to Remove in WordPress Migrations”
ScriptSizeNeeded?
jquery.min.js87KBRarely — rewrite in vanilla JS
jquery-migrate.min.js10KBNever — WordPress compatibility shim
wp-embed.min.js2KBNever — WordPress oEmbed
wp-emoji-release.min.js14KBNever — WordPress emoji polyfill
Divi/Elementor builder JS200-500KBNever in production
  • All scripts use defer or async
  • Third-party scripts delayed until user interaction
  • jQuery removed (replaced with vanilla JS if needed)
  • WordPress-specific JS removed (emoji, embed, migrate)
  • Page builder JS removed (Divi, Elementor)
  • Page-specific code split into separate files
  • All JS files minified
  • Total JS under 100KB (excluding third-party)

@font-face {
font-family: 'Inter';
src: url('/fonts/inter-var.woff2') format('woff2');
font-weight: 100 900;
font-display: swap; /* Show fallback font immediately, swap when loaded */
}
ValueBehaviorCLS RiskRecommendation
autoBrowser decidesHighAvoid
blockInvisible text for up to 3sHighAvoid
swapFallback shown immediatelyMediumUse this
fallbackBrief invisible (100ms), then fallbackLowGood alternative
optionalMay skip custom font if slowNoneBest for performance

Google Fonts adds an external DNS lookup and request chain. Self-hosting eliminates this latency.

  1. Download fonts from google-webfonts-helper or directly from Google Fonts

  2. Create @font-face declarations:

    @font-face {
    font-family: 'Inter';
    font-style: normal;
    font-weight: 100 900;
    font-display: swap;
    src: url('/fonts/inter-var.woff2') format('woff2');
    }
  3. Remove Google Fonts links and replace with self-hosted:

    <!-- REMOVE -->
    <link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet">
    <!-- REPLACE -->
    <link rel="stylesheet" href="/css/fonts.css">
  4. Preload critical fonts:

    <link rel="preload" href="/fonts/inter-var.woff2" as="font"
    type="font/woff2" crossorigin>
TechniqueSavingsHow
Use WOFF2 only30% vs WOFFDrop WOFF/TTF/EOT formats
Variable fonts40-60%One file instead of multiple weights
Subset fonts50-80%Include only Latin characters
Limit weights30-60%Use 2-3 weights max
  • All fonts use font-display: swap
  • Critical fonts preloaded in <head>
  • Google Fonts self-hosted
  • Only WOFF2 format used
  • Variable fonts used where available
  • Maximum 2-3 font families
  • Fonts subsetted to required character ranges
  • Total font weight under 100KB

public/_headers
# HTML pages - short cache, always revalidate
/*.html
Cache-Control: public, max-age=3600, must-revalidate
/
Cache-Control: public, max-age=3600, must-revalidate
# CSS/JS - long cache with content hash in filename
/css/*
Cache-Control: public, max-age=31536000, immutable
/js/*
Cache-Control: public, max-age=31536000, immutable
# Images and fonts - long cache
/images/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
Access-Control-Allow-Origin: *
# Other files
/favicon.ico
Cache-Control: public, max-age=86400
/sitemap.xml
Cache-Control: public, max-age=86400
/robots.txt
Cache-Control: public, max-age=86400
DirectiveMeaningExample Use
publicCan be cached by CDN and browserAll assets
max-age=NCache for N seconds3600 = 1 hour, 31536000 = 1 year
immutableNever changes, skip revalidationHashed filenames
must-revalidateCheck with server when cache expiresHTML pages
no-cacheAlways revalidate before using cached copyDynamic content
stale-while-revalidate=NServe stale while fetching fresh in backgroundBalance freshness/speed
flowchart TD
    A[Browser Cache - user's device] -->|if expired| B[Cloudflare Edge Cache - nearest POP]
    B -->|if expired| C[Cloudflare Pages Origin - build output]

Key behaviors:

  • Cloudflare respects Cache-Control headers from _headers
  • Deploy automatically purges the CDN cache
  • immutable tells both browser and CDN to never revalidate
<!-- Filename includes content hash - changes when file changes -->
<link rel="stylesheet" href="/css/style.a1b2c3d4.css">
<script defer src="/js/app.e5f6g7h8.js"></script>

public/_headers
# Global headers
/*
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
X-XSS-Protection: 1; mode=block
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://www.google-analytics.com; frame-src 'self' https://www.youtube.com https://www.google.com; base-uri 'self'; form-action 'self'
# HTML pages
/*.html
Cache-Control: public, max-age=3600, must-revalidate
/
Cache-Control: public, max-age=3600, must-revalidate
# Static assets
/css/*
Cache-Control: public, max-age=31536000, immutable
/js/*
Cache-Control: public, max-age=31536000, immutable
/images/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
Access-Control-Allow-Origin: *
/favicon.ico
Cache-Control: public, max-age=86400
/sitemap.xml
Cache-Control: public, max-age=86400
/robots.txt
Cache-Control: public, max-age=86400
HeaderValuePurpose
X-Frame-OptionsSAMEORIGINPrevents clickjacking (iframe embedding)
X-Content-Type-OptionsnosniffPrevents MIME-sniffing
Referrer-Policystrict-origin-when-cross-originControls referrer info sharing
Permissions-Policycamera=(), ...Disables unused browser features
Strict-Transport-Securitymax-age=31536000; includeSubDomains; preloadForces HTTPS for 1 year
Content-Security-PolicySee belowDeclares allowed resource sources
Content-Security-Policy:
default-src 'self'; # Default: only same-origin
script-src 'self' 'unsafe-inline' # Scripts: self + inline
https://www.googletagmanager.com # + GTM
https://www.google-analytics.com; # + GA
style-src 'self' 'unsafe-inline'; # Styles: self + inline
img-src 'self' data: https:; # Images: self + data URIs + HTTPS
font-src 'self'; # Fonts: self only
connect-src 'self' # XHR/Fetch: self + GA
https://www.google-analytics.com;
frame-src 'self' # Iframes: self + embeds
https://www.youtube.com
https://www.google.com;
base-uri 'self'; # Restrict <base> tag
form-action 'self'; # Forms submit to self only

Testing: Verify at https://securityheaders.com/ — target grade A or A+.


A step-by-step process to take a migrated site from a typical WordPress score (30-60) to the target (90+ mobile).

Phase 1: Quick Wins (30 minutes) — +20 to +35 points

Section titled “Phase 1: Quick Wins (30 minutes) — ”
  • Enable Cloudflare features: Auto Minify, Brotli, HTTP/3, Early Hints
  • Add _headers file with cache and security headers
  • Add defer to all <script> tags
  • Add loading="lazy" to all below-fold images
  • Add width and height to all <img> tags
  • Set fetchpriority="high" on the hero/LCP image

Phase 2: Image Optimization (1-2 hours) — +10 to +20 points

Section titled “Phase 2: Image Optimization (1-2 hours) — ”
  • Convert all images to WebP (Sharp build script)
  • Resize images to maximum needed dimensions (1920px max)
  • Create responsive image variants for hero and large images
  • Remove unused images from build output
  • Verify no image exceeds 200KB

Phase 3: CSS and Font Optimization (1-2 hours) — +5 to +15 points

Section titled “Phase 3: CSS and Font Optimization (1-2 hours) — ”
  • Extract and inline critical CSS in <head>
  • Load non-critical CSS asynchronously
  • Run PurgeCSS to remove unused styles
  • Self-host Google Fonts
  • Add font-display: swap to all @font-face
  • Preload critical fonts, use WOFF2 format only

Phase 4: JavaScript Optimization (1-2 hours) — +5 to +15 points

Section titled “Phase 4: JavaScript Optimization (1-2 hours) — ”
  • Remove jQuery and WordPress-specific scripts
  • Remove page builder JavaScript (Divi, Elementor)
  • Delay third-party scripts until user interaction
  • Verify total JS under 100KB (excluding delayed third-party)

Phase 5: Fine-Tuning (30 minutes) — +5 to +10 points

Section titled “Phase 5: Fine-Tuning (30 minutes) — ”
  • Run Lighthouse and address specific remaining issues
  • Check CLS — ensure no layout shifts on page load
  • Check LCP element loads within 2.5s
  • Test on real mobile device
  • Submit to PageSpeed Insights for final score
  • Set up Search Console Core Web Vitals monitoring
  • Check PageSpeed Insights monthly
  • Review Cloudflare Analytics for edge cache hit rate
  • Test after every deployment

MetricWordPress (Before)Cloudflare Pages (After)Change
Performance35-5592-100+40 to +65
Accessibility70-8590-100+10 to +25
Best Practices65-8095-100+20 to +30
SEO80-9095-100+10 to +15
MetricWordPress (Before)Cloudflare Pages (After)Target
LCP4.5-8.0s0.8-2.0s< 2.5s
INP150-400ms20-80ms< 200ms
CLS0.15-0.450.0-0.05< 0.1
TTFB800ms-2.5s20-80ms< 800ms
ResourceWordPress (Before)Cloudflare Pages (After)Reduction
HTML80-150KB15-30KB70-80%
CSS300-600KB20-50KB85-92%
JavaScript500KB-1.5MB10-50KB90-97%
Images2-8MB200KB-1MB75-90%
Fonts200-500KB30-80KB75-85%
Total3-10MB300KB-1.2MB80-92%
FactorWordPressCloudflare Pages
ServerShared PHP server, 800ms+ TTFBCDN edge, 20-80ms TTFB
CSSFull theme + 10 plugin stylesheetsPurged, critical-inlined
JavaScriptjQuery + plugins + builder JSMinimal vanilla JS, deferred
ImagesFull-size JPEGs, often uncompressedWebP, responsive, lazy-loaded
FontsGoogle Fonts (external), multiple weightsSelf-hosted WOFF2, 2-3 weights
CachingOften misconfigured or absentOptimized _headers file

MetricTargetHow to Measure
Lighthouse Mobile90+Chrome DevTools > Lighthouse
Lighthouse Desktop95+Chrome DevTools > Lighthouse
LCP< 2.5sPageSpeed Insights
INP< 200msPageSpeed Insights (field data)
CLS< 0.1PageSpeed Insights
TTFB< 200msChrome DevTools > Network
Total Page Weight< 1.5MBChrome DevTools > Network
HTTP Requests< 25Chrome DevTools > Network
Resource TypeBudget
Total HTML< 50KB
Total CSS< 50KB
Total JS (first-party)< 100KB
Total Images (above fold)< 200KB
Total Fonts< 100KB
Single Image (max)< 200KB
Hero Image< 150KB
ToolPurposeURL
PageSpeed InsightsLighthouse + field datahttps://pagespeed.web.dev/
WebPageTestDetailed waterfall analysishttps://www.webpagetest.org/
SquooshManual image optimizationhttps://squoosh.app/
PurgeCSSRemove unused CSShttps://purgecss.com/
CriticalExtract critical CSSgithub.com/addyosmani/critical
Security HeadersTest security headershttps://securityheaders.com/