Development

How I Got a 100 PageSpeed Insights Score on Mobile and Desktop

I recently set a strict target for mikelopez.com: earn a 100 PageSpeed Insights score on both mobile and desktop, without lowering the bar for accessibility, best practices, or SEO.

The starting point was 67 for mobile performance and 93 for desktop. Accessibility was 94. Best Practices and SEO were already at 100. The mobile Largest Contentful Paint was 9.1 seconds, and PageSpeed estimated that better image delivery could save about 1.9 MiB.

I reached 100 across all four scored categories on both profiles by combining a small static HTML architecture, AI-assisted investigation, disciplined image handling, automated regression tests, and Cloudflare Pages. The last few points did not come from one magic setting. They came from measuring the browser's actual behavior, fixing one constraint at a time, and treating 99 as a failed build.

How did I get a 100 PageSpeed Insights score?

Quick answer: I generate the site as static HTML, inline its small page-specific CSS, serve responsive and metadata-free images, reserve every image's dimensions, prioritize only the LCP image, and defer later image URLs until after the initial load. I use Cloudflare Pages for delivery and a generated _headers file to prevent unwanted HTML transformation. An AI coding assistant helped inspect reports, trace network behavior, implement narrow fixes, and add tests, but every result was verified with Lighthouse and the deployed PageSpeed Insights reports.

The process matters more than any individual optimization. A fast architecture gave me room to work, but the perfect scores came from a repeatable release gate.

The static HTML foundation

This site used to carry infrastructure it no longer needed. I replaced that stack with a small PHP static site generator:

  1. Markdown files in content/ are the source of truth.
  2. PHP parses the content and renders templates.
  3. The build writes deployable HTML into htdocs/.
  4. Cloudflare Pages publishes that directory from GitHub.

Public requests do not need PHP, WordPress, a database, theme bootstrapping, plugin initialization, or server-side template work. The browser receives finished HTML.

Static HTML does not automatically guarantee a 100 score. A static page can still ship oversized images, blocking stylesheets, unnecessary JavaScript, weak cache behavior, layout shifts, and poor accessibility. It does remove several sources of latency and complexity, which makes the remaining problems easier to isolate.

That was an important architectural advantage. I was optimizing a short and deterministic request path instead of trying to compensate for runtime work with more caching layers.

How I used AI without handing it the steering wheel

I gave the AI coding assistant two PageSpeed Insights reports, the repository, and one measurable requirement: all four categories must score 100 on mobile and desktop.

The assistant helped with work that benefits from fast, broad inspection:

  • Comparing the mobile and desktop reports
  • Locating the templates and helpers responsible for each resource
  • Measuring source and generated image dimensions
  • Forming testable hypotheses about LCP and request priority
  • Adding responsive image generation and regression checks
  • Rebuilding, auditing, and comparing repeated runs
  • Inspecting production HTML and network requests after deployment

I did not accept a score prediction or a code change as proof. The browser remained the authority. When local Lighthouse reached 100 but production PageSpeed varied between 98 and 100, the job was not finished.

That distinction is important in AI-assisted development. AI is useful for compressing the investigate-change-test loop. It is not a substitute for observing the real system. I use the same principle when I review AI-generated PHP code: verify assumptions against code, tests, runtime behavior, and primary documentation.

Responsive images removed the largest waste

The homepage initially presented ten featured images from 1536x1024 sources. Those dimensions are appropriate for article sharing and large displays, but they are wasteful for a mobile card that renders only a few hundred CSS pixels wide.

The build now creates two smaller JPEG variants for each featured image:

  • 640 pixels wide for narrower displays
  • 768 pixels wide for wider cards and higher-density screens
  • The original 1536 pixel source remains available when the browser genuinely needs it

Each image is rendered with an accurate srcset and sizes expression. The browser chooses the best candidate for its viewport and pixel density. I do not need separate editorial images for mobile and desktop, and a phone no longer receives the full source by default.

The site logo follows the same idea with 48 pixel and 96 pixel versions for standard and high-density displays.

The web.dev responsive image guide explains the browser primitives behind this approach. The practical lesson is simple: generate the sizes your layout uses, then describe the layout accurately enough for the browser to choose well.

Every rendered image also includes intrinsic width and height. That lets the browser reserve the correct space before the file arrives and keeps Cumulative Layout Shift at zero.

I optimized files and stripped their metadata

Resizing was only half of the image pipeline. Every deployable JPG, JPEG, and PNG is optimized after its final export or resize.

For JPEG files, the build enforces:

jpegoptim --strip-all --all-progressive --max=85 image.jpg

For PNG files, it enforces:

optipng -o7 -strip all -- image.png

These commands remove metadata and reduce transfer size without relying on a person to remember a manual step. The build tracks optimized file hashes so repeated builds do not recompress an unchanged image. That protects quality and keeps generated output deterministic.

I also added synthetic regression fixtures containing JPEG and PNG metadata. The test suite runs the real optimizers and confirms that the marker is gone afterward. A written rule is useful. A test that fails when the rule is broken is better.

Only one image gets LCP priority

The first homepage card is the likely Largest Contentful Paint candidate, so it receives:

loading="eager" fetchpriority="high"

Later images retain low-priority lazy-loading attributes. Article featured images are also eager and high priority because they are likely to become the LCP element on an article page.

The important part is scarcity. If every image is high priority, none of them is meaningfully prioritized. The renderer assigns high priority only once and the regression suite verifies that count.

Native lazy loading was not enough

This was the most interesting problem in the final stretch.

Local Lighthouse had reached 100, but production PageSpeed Insights still moved between 98 and 100. Total Blocking Time and Cumulative Layout Shift were both zero, so JavaScript execution and layout instability were not the cause.

The network trace showed that Chrome was fetching six below-the-fold card images while the LCP image was still downloading. They all had loading="lazy", but browsers intentionally begin some lazy requests before an image enters the viewport. That normally improves scrolling. On this page, those early downloads competed with the one resource that determined the score.

I changed noncritical card images so their src, srcset, and sizes begin in data-* attributes. A small deferred script waits for the window load event, then uses IntersectionObserver with a 300 pixel margin to activate images shortly before they are needed.

The image still has intrinsic dimensions, alternative text, loading="lazy", decoding="async", and fetchpriority="low". The only withheld information is the resource URL that would place it in the initial request queue.

That reduced the local mobile transfer during the Lighthouse run from about 312 KB to 96 KB and lowered mobile LCP from roughly 1.65 seconds to 1.43 seconds. More importantly, two fresh production PageSpeed runs then held at 100.

The same deferred responsive pipeline now handles the real screenshots in this article, so evidence images do not compete with the featured image.

Small CSS and no render-blocking stylesheet

The site's stylesheet is small, so the generator places only the CSS needed by the current page directly in the document. The browser can render without waiting for a separate stylesheet request.

The generator keeps page-specific sections separate. The homepage does not receive music or services layout rules it will never use. This preserves separation of concerns while avoiding a render-blocking request.

The accessibility score also exposed a contrast problem in the original gold accent. I changed the light-background accent to a darker #866a12 while keeping the brighter gold for dark surfaces. Performance work should not trade away readability to gain a point elsewhere.

Cloudflare Pages completed the delivery path

Cloudflare Pages serves the generated files close to visitors, but I still verified what Cloudflare delivered rather than assuming the origin HTML would remain unchanged.

The production trace revealed injected render-path behavior, including Rocket Loader and duplicate analytics work. I generated a route-specific _headers file with this HTML policy:

Cache-Control: public, max-age=0, must-revalidate, no-transform

Cloudflare documents how Pages projects can set response headers with _headers. Its Web Analytics FAQ also explains that public, no-transform prevents the proxy from modifying the original payload for automatic beacon injection.

I scoped the rule to HTML routes so it would not replace the existing image cache behavior. After deployment, I fetched the public HTML and response headers again. Rocket Loader and the duplicate zone injection were gone, while the single Pages Analytics beacon configured for the Pages project remained.

The broader lesson is that CDN configuration is part of the application. Test the final response from the public hostname, not just the files in the build directory.

The release gate made 100 repeatable

A single Lighthouse result can be lucky. Scores vary with the test environment, browser version, network simulation, and response timing. The Lighthouse scoring documentation explains how measured metrics are converted into the 0 to 100 performance score.

I turned the target into a project rule:

  • Performance, Accessibility, Best Practices, and SEO must each score 100
  • Both mobile and desktop must pass
  • Every audited route needs two consecutive passing runs
  • A 99 is a failure
  • Lighthouse stays pinned to version 13.4.1 until the baseline is intentionally updated
  • New posts test both the homepage and the article URL
  • Production is checked again with PageSpeed Insights after deployment

The automated suite also checks responsive variants, srcset, sizes, intrinsic dimensions, loading priority, deferred image attributes, contrast, Cloudflare headers, metadata removal, and repeat-build stability.

This is what makes the result useful beyond one screenshot. Future content goes through the same process.

The final mobile PageSpeed Insights result

The second fresh production mobile run scored 100 for Performance, Accessibility, Best Practices, and SEO. It recorded a 0.9 second First Contentful Paint, 1.7 second Largest Contentful Paint, zero Total Blocking Time, and zero Cumulative Layout Shift.

PageSpeed Insights mobile report for mikelopez.com showing 100 for Performance, Accessibility, Best Practices, and SEO

Mobile result captured from the live PageSpeed Insights report.

The final desktop PageSpeed Insights result

The second fresh production desktop run also scored 100 in all four categories. First Contentful Paint and Largest Contentful Paint were both 0.3 seconds, Total Blocking Time was zero, and Cumulative Layout Shift was zero.

PageSpeed Insights desktop report for mikelopez.com showing 100 for Performance, Accessibility, Best Practices, and SEO

Desktop result captured from the live PageSpeed Insights report.

The result table is straightforward:

ProfileBeforeAfterFinal LCPTBTCLS
Mobile performance671001.7 s0 ms0
Desktop performance931000.3 s0 ms0
Mobile accessibility94100
Desktop accessibility94100
Best Practices100100
SEO100100

My practical 100 score checklist

If I were repeating this work on another small content site, I would use this order:

  1. Remove runtime work the public page does not need. Static HTML is a strong default for content that changes only when published.
  2. Audit mobile first. Its throttling exposes transfer and rendering mistakes more clearly.
  3. Identify the real LCP element. Give high priority to that resource only.
  4. Generate responsive image widths. Match candidates to the layout instead of shipping one large source everywhere.
  5. Declare accurate srcset, sizes, width, and height. Let the browser choose correctly and reserve layout space.
  6. Optimize JPEG and PNG files after resizing. Strip all metadata and make the step automatic.
  7. Measure native lazy loading. Do not assume the word lazy means the request will stay out of the initial network queue.
  8. Inline genuinely small critical CSS. Keep unrelated page rules out of the document.
  9. Inspect CDN-modified production HTML. Confirm headers, injected scripts, caching, and final resource order.
  10. Add regression tests for every important constraint. Protect the result from the next content or template change.
  11. Run each profile more than once. Fix variability instead of publishing the best run.

Frequently Asked Questions

Does static HTML guarantee a 100 PageSpeed Insights score?

No. Static HTML removes server-side rendering and database work from the public request, but the page can still be slow because of images, CSS, JavaScript, third-party services, fonts, or CDN configuration. It is a clean foundation, not a guarantee.

Did AI automatically optimize the site?

No. AI accelerated repository inspection, hypothesis generation, implementation, and repetitive verification. Lighthouse, browser network traces, automated tests, and deployed PageSpeed reports determined whether each change worked.

Do mobile and desktop receive different image sizes?

Yes. The HTML exposes 640 pixel, 768 pixel, and original image candidates through srcset, plus an accurate sizes expression. The browser selects the smallest useful candidate for the layout and display density.

Why not rely only on loading="lazy"?

Browsers may fetch lazy images before they enter the viewport to make scrolling feel faster. That behavior can still create initial network competition. Measure the request trace. On this site, withholding noncritical resource URLs until after load produced a stable result.

Does Cloudflare automatically make a site score 100?

No. Cloudflare improves the delivery path, but origin assets, HTML structure, resource priority, caching rules, transformations, and third-party scripts still matter. Verify the response actually served to visitors.

Is 100 more important than real user experience?

No. A laboratory score is a diagnostic signal, not the product goal. The useful outcome here was smaller transfers, faster meaningful rendering, zero layout shift, accessible contrast, and a build process that prevents regressions. The 100 score is evidence that those constraints currently work together.

Make the score a process, not a screenshot

The biggest improvement was not static HTML, AI, responsive images, or Cloudflare by itself. It was connecting all of them to a measurable release gate.

Static generation made the delivery path small. AI made investigation faster. Responsive images and deliberate priority removed network competition. Cloudflare Pages delivered the result. Tests made the rules durable.

If you want to try the same approach, test your own site with PageSpeed Insights, inspect the slowest profile first, and turn every successful fix into an automated rule.