Part of the guide: Laravel on cPanel & Shared Hosting: The Complete Guide

Improve PageSpeed Score on Shared Hosting: 61 to 95

By Published 23 min read Hosting
Improve PageSpeed Score on Shared Hosting: 61 to 95

Why was my PageSpeed score only 61 on shared hosting?

Can you improve your PageSpeed score on shared hosting without moving to a VPS or buying a CDN? Yes. But the first thing to fix might have nothing to do with your hosting provider.

On 22 October 2026, I worked through this problem on HeadsNTail, which runs plain PHP on a Namecheap shared cPanel plan. The homepage scored 61 on mobile, and Largest Contentful Paint (LCP) was 6.9 seconds.

My first guess was the server.

I was wrong.

The largest image on the homepage wasn't even in the original HTML. JavaScript had to build the hero carousel before the browser could discover it.

The direct fix: Open the Lighthouse JSON report, find the actual LCP element, and remove whatever delays that element from rendering. On shared hosting you often don't need a server upgrade. The changes that moved my score were:

  • server-rendered hero images
  • correct loading priorities
  • fewer render-blocking CSS files
  • responsive images
  • animation fixes
  • page caching

Here's what the measurements showed and why each change mattered.

Start with the Lighthouse JSON, not the performance score

A PageSpeed score is an output, not a diagnosis.

Two runs can show different scores even when the source code hasn't changed. Mobile tests use simulated device and network limits. On top of that, CPU contention, server cache state and resource timing all vary from run to run.

The Lighthouse 13 report I inspected used these settings:

SettingValue
Emulated deviceMoto G Power
NetworkSlow 4G (simulated)
Device pixel ratio1.75
Test targetHeadsNTail homepage
HostingNamecheap shared cPanel
ApplicationPlain PHP
CDNNone

Mobile scores typically move by several points between runs. The swings get bigger when JavaScript and third-party resources affect the loading sequence.

A cold shared-hosting process can also produce a low first result that doesn't represent a warmed server.

How to capture a useful Lighthouse report

  1. Open Chrome in Incognito mode.
  2. Open the homepage.
  3. Press F12.
  4. Select the Lighthouse tab.
  5. Choose Mobile and Performance.
  6. Generate the report.
  7. Export it using Save as JSON.

Keep both the HTML report and the JSON when possible.

The JSON contains the underlying audits, including the element Lighthouse selected as LCP and the exact emulation settings used for the run.

To inspect a saved report with Node.js, save this as inspect-lighthouse.js:

const fs = require('node:fs');

const report = JSON.parse(
  fs.readFileSync('lighthouse.json', 'utf8')
);

const audits = report.audits;

// Confirms which Lighthouse version and device emulation produced this report.
console.log({
  version: report.lighthouseVersion,
  emulation: report.configSettings?.screenEmulation
});

console.log({
  score: Math.round(report.categories.performance.score * 100),
  lcp: audits['largest-contentful-paint']?.displayValue,
  fcp: audits['first-contentful-paint']?.displayValue,
  tbt: audits['total-blocking-time']?.displayValue,
  cls: audits['cumulative-layout-shift']?.displayValue
});

// The LCP breakdown insight ID changed between Lighthouse versions.
const lcpBreakdown =
  audits['lcp-breakdown-insight'] ?? audits['lcp-phases-insight'];

console.dir(lcpBreakdown?.details, { depth: 8 });

Run it from the folder that contains lighthouse.json:

node inspect-lighthouse.js

This tells you far more than refreshing PageSpeed Insights and comparing the number at the top. Save one JSON per change so you can see which fix actually moved which metric.

Don't test with browser extensions enabled

One unexpected problem cost me time.

With my ad blocker enabled, the same page reported a Total Blocking Time of 5,340 ms.

In Incognito, it reported 230 ms.

The remaining work came from Google's AdSense and Analytics scripts.

That difference is too large to ignore. Extensions inject their own scripts and change how the page's scripts execute, which invalidates comparisons between runs.

Use a clean browser profile, consistent Lighthouse settings and several runs. Save each JSON file so you can compare the actual bottlenecks.

The original hero carousel was built entirely in JavaScript.

Conceptually, it worked like this:

<div id="hero-carousel"></div>

<script src="/assets/js/home.js" defer></script>

Then JavaScript created the slides:

const carousel = document.querySelector('#hero-carousel');

carousel.innerHTML = `
  <div class="slide">
    <img
      src="/assets/images/hero.webp"
      loading="lazy"
      alt="Latest developer tutorials"
    >
  </div>
`;

There were two problems:

  1. The browser couldn't discover the hero image while parsing the HTML.
  2. The first image was marked loading="lazy" even though it filled the first screen.

So the browser had to download and run JavaScript before it even knew the image existed, and then the lazy attribute told it to wait even longer.

The Lighthouse LCP breakdown showed:

LCP componentMeasured delay
Resource load delay715 ms
Element render delay640 ms

A faster server wouldn't remove either of those delays.

Render the first carousel slide in PHP

I changed the implementation so PHP generates the slide markup:

<?php
$slides = [
    [
        'image' => '/assets/images/hero.webp',
        'title' => 'Latest developer tutorials',
        'url'   => '/blog',
    ],
    [
        'image' => '/assets/images/hosting.webp',
        'title' => 'Hosting guides',
        'url'   => '/hosting',
    ],
];

function e(string $value): string
{
    return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
?>

<div class="hero-carousel">
    <?php foreach ($slides as $index => $slide): ?>
        <a class="hero-slide" href="<?= e($slide['url']) ?>"<?= $index !== 0 ? ' hidden' : '' ?>>
            <img
                src="<?= e($slide['image']) ?>"
                alt="<?= e($slide['title']) ?>"
                width="1200"
                height="630"
                <?php if ($index === 0): ?>
                loading="eager"
                fetchpriority="high"
                <?php else: ?>
                loading="lazy"
                <?php endif; ?>
            >
        </a>
    <?php endforeach; ?>
</div>

The browser now finds the primary image as soon as the HTML arrives.

The JavaScript carousel still handles navigation and transitions. It just no longer has to build the initial content.

The hidden attribute keeps the later slides out of the initial layout until the carousel shows them. Because they're display: none and lazy, their images don't download until they're needed.

Preload the actual LCP image

I also added a preload inside <head>:

<link
  rel="preload"
  as="image"
  href="/assets/images/hero.webp"
  fetchpriority="high"
>

Don't preload every carousel image. Preloading images that aren't initially visible makes them compete with the real LCP image for bandwidth.

Make sure the preload URL matches the image the browser actually requests. A mismatch causes a wasted second download.

If your hero image uses srcset, a plain href preload will usually fetch the wrong size. Use imagesrcset and imagesizes with the same values as the <img>:

<link
  rel="preload"
  as="image"
  href="/assets/images/hero-960.webp"
  imagesrcset="/assets/images/hero-640.webp 640w, /assets/images/hero-960.webp 960w"
  imagesizes="100vw"
  fetchpriority="high"
>

How to verify: re-run Lighthouse and open the LCP discovery insight. It should confirm three things:

  • the image is discoverable in the initial HTML
  • it isn't lazy-loaded
  • it uses fetchpriority="high"

After fixing image discovery and loading priority, LCP improved from 6.9 seconds to about 2.4 seconds at this stage of testing.

That still wasn't the final result.

Why carousel autoplay pushed LCP to 7 seconds

This was the most frustrating part of the work.

After fixing the HTML, I still saw LCP values of:

  • 3.0 seconds
  • 5.2 seconds
  • 7.2 seconds

The code hadn't changed between those runs.

The problem was autoplay.

The carousel changed slides while Lighthouse was still recording LCP. Chrome reports a new LCP candidate whenever a newly painted element is larger than the current one. A slide transition could trigger that in two ways:

  • the next slide's visible area was larger than the first slide's
  • the first slide hadn't finished painting when rotation started

Either way, the reported LCP timestamp moved later.

The fix wasn't a new server or another image compressor. It was delaying autoplay:

const carousel = document.querySelector('.hero-carousel');
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

let autoplayStarted = false;

function startAutoplay() {
  if (autoplayStarted || !carousel || reduceMotion) return;

  autoplayStarted = true;

  window.setInterval(() => {
    if (document.hidden) return;

    // Your carousel controller must listen for this event and advance the slide.
    carousel.dispatchEvent(new CustomEvent('carousel:next'));
  }, 5000);
}

// Wait long enough that rotation doesn't overlap the initial LCP measurement.
window.setTimeout(startAutoplay, 12000);

// Chrome stops recording LCP after the first user interaction,
// so starting rotation after an interaction can't change LCP.
carousel?.addEventListener('pointerdown', startAutoplay, { once: true });
carousel?.addEventListener('keydown', startAutoplay, { once: true });

The important detail is the 12-second delay before automatic rotation begins. Lighthouse never interacts with the page, so that timer is what keeps rotation out of the measurement.

That stopped autoplay from interfering with the initial LCP measurement. It also gives visitors time to read the first slide instead of having the main content move immediately.

For accessibility, an auto-rotating carousel should also have a visible pause button. The prefers-reduced-motion check above turns rotation off for visitors who asked for less motion.

When not to use this fix: if your LCP is stable across runs and the LCP element isn't inside the carousel, delaying autoplay won't solve an unrelated problem.

Remove render delays: animations, CSS and images

Reveal-on-scroll animations were hiding the first screen

Another problem came from entrance animations.

The homepage used reveal effects that started content invisible:

.reveal {
  opacity: 0;
  transform: translateY(20px);
  transition:
    opacity 400ms ease,
    transform 400ms ease;
}

.reveal.is-visible {
  opacity: 1;
  transform: none;
}

That looks harmless.

But when above-the-fold content starts at opacity: 0, the browser can have the HTML and images ready and still be unable to show them. It has to wait for JavaScript to add is-visible. If the script fails, the content never appears.

The original implementation also ran repeated geometry checks on scroll.

Lighthouse flagged forced reflow at:

Script locationMeasured time
polish.js:3536 ms
home.js:52331 ms

Calling getBoundingClientRect() and then writing to the DOM in a loop forces the browser to calculate layout synchronously, again and again.

Use IntersectionObserver and never hide what's already on screen

A cleaner approach is to let content render visible by default. JavaScript hides only what is still off-screen.

const root = document.documentElement;
const elements = document.querySelectorAll('.reveal');
let firstBatch = true;

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;

      entry.target.classList.add('is-visible');
      observer.unobserve(entry.target);
    }

    // The first callback reports every observed element. Anything visible
    // is already marked, so only off-screen elements get hidden below.
    if (firstBatch) {
      firstBatch = false;
      root.classList.add('js-ready');
    }
  },
  { threshold: 0.05 }
);

elements.forEach((element) => observer.observe(element));

The matching CSS:

.reveal {
  transition:
    opacity 400ms ease,
    transform 400ms ease;
}

/* Only hide elements once JavaScript has confirmed they're off-screen. */
.js-ready .reveal:not(.is-visible) {
  opacity: 0;
  transform: translateY(20px);
}

@media (prefers-reduced-motion: reduce) {
  .reveal {
    transition: none;
  }

  .js-ready .reveal:not(.is-visible) {
    opacity: 1;
    transform: none;
  }
}

There are no geometry reads in a scroll handler, and nothing above the fold is hidden for even a frame. If the script fails, everything stays visible.

Don't put the reveal class on the hero or any other content that has to appear immediately. For first-screen content, no entrance animation is usually the better choice.

The goal isn't to remove every animation. It's to stop decorative effects from delaying visible content or repeatedly forcing layout.

Combine render-blocking CSS without breaking asset URLs

The homepage originally loaded eight separate CSS files, and each one blocked rendering.

On HTTP/2, multiple CSS files aren't automatically bad. But when all of them block rendering and each holds a few small, overlapping rules, combining them still helps.

I used a small PHP build script that:

  1. Reads a defined list of CSS files in order.
  2. Rewrites relative url() references so they still point to the right files.
  3. Generates a content-hashed filename.
  4. Records that filename so templates can load it.
  5. Lets Apache serve the result with a 30-day cache lifetime.

The URL rewriting is the step people miss. Suppose the CSS includes:

.card {
  background-image: url("../images/card-bg.webp");
}

The stylesheet moves from:

/assets/css/components/cards.css

To:

/assets/cache/site-abc123.css

That relative image path no longer points to the same place. The bundler has to resolve it against the original stylesheet's location.

Here's a minimal version. Save it as build-css.php next to your public folder. It needs PHP 8.0+ because it uses str_starts_with:

<?php

$public = realpath(__DIR__ . '/public');

if ($public === false) {
    throw new RuntimeException('Public directory missing');
}

$files = [
    '/assets/css/reset.css',
    '/assets/css/layout.css',
    '/assets/css/components.css',
];

$combined = '';

foreach ($files as $webPath) {
    $path = $public . $webPath;

    if (!is_file($path)) {
        throw new RuntimeException("Missing CSS: $webPath");
    }

    $css = file_get_contents($path);

    if ($css === false) {
        throw new RuntimeException("Cannot read: $path");
    }

    // @charset is only valid at the very start of a file; drop it from merged parts.
    $css = preg_replace('~@charset\s+["\'][^"\']*["\']\s*;~i', '', $css);

    $directory = dirname($webPath);

    // Rewrite relative url() paths so they resolve from the original file's folder.
    $css = preg_replace_callback(
        '~url\(\s*(["\']?)([^)\'"]+)\1\s*\)~i',
        function (array $matches) use ($directory): string {
            $url = trim($matches[2]);

            if (
                str_starts_with($url, '/') ||
                preg_match('~^(data:|https?:|//|#)~i', $url)
            ) {
                return $matches[0];
            }

            return 'url("' . $directory . '/' . $url . '")';
        },
        $css
    );

    $combined .= "\n" . $css;
}

$hash = substr(hash('sha256', $combined), 0, 16);
$webOutput = "/assets/cache/site-$hash.css";
$output = $public . $webOutput;

if (!is_dir(dirname($output))) {
    mkdir(dirname($output), 0755, true);
}

if (!is_file($output)) {
    file_put_contents($output, $combined, LOCK_EX);
}

// Record the current bundle name so templates know which file to load.
file_put_contents(__DIR__ . '/css-bundle.txt', $webOutput, LOCK_EX);

echo $webOutput . PHP_EOL;

Run it during deployment, not on every request:

php build-css.php

On cPanel, the php on the command line can be a different version from the one your site uses. If you see Call to undefined function str_starts_with(), call the PHP 8 binary your host provides by its full path.

Then load the generated file in your template:

<?php
$bundledCss = trim((string) file_get_contents(__DIR__ . '/../css-bundle.txt'));
?>
<link rel="stylesheet" href="<?= htmlspecialchars($bundledCss, ENT_QUOTES, 'UTF-8') ?>">

Adjust the css-bundle.txt path to wherever your template lives relative to the build script.

This is a starting point, not a minifier. It doesn't strip comments or whitespace, and it doesn't inline @import rules. An @import left in the middle of a merged file is ignored by browsers. For production, use a parser-backed minifier rather than more regular expressions.

The hash in the filename means a changed bundle gets a new URL, so browsers never get stuck with a stale stylesheet.

For Apache, a cache rule can look like this:

<IfModule mod_headers.c>
    <FilesMatch "^site-[a-f0-9]{16}\.css$">
        Header set Cache-Control "public, max-age=2592000"
    </FilesMatch>
</IfModule>

Because the filename changes whenever the content changes, you can safely raise this to max-age=31536000, immutable later.

How to verify: open DevTools → Network, reload, and click the bundle. Check that the response shows Content-Type: text/css and your Cache-Control value. Then check that background images referenced from the CSS still load with no 404s.

The recorded render-blocking time dropped from about 900 ms to 150 ms in the Lighthouse audit.

If you're editing Apache caching rules, the HeadsNTail .htaccess generator is a useful companion. Keep performance directives separate from the security policies covered in the cPanel security headers guide.

Why I didn't inline critical CSS

Inlining critical CSS can speed up first render when a stylesheet is the real bottleneck.

But it duplicates CSS between the HTML and the external files, complicates maintenance, and adds work every time a template changes.

For this site, bundling and caching removed most of the problem without a critical-CSS generation pipeline.

I'd only revisit inlining if later Lighthouse reports clearly showed meaningful render delay still caused by CSS.

Stop sending 1600 px images into 300 px cards

One image-delivery audit showed an image weighing 184 KiB when a properly sized version could be much smaller.

After resizing and converting the relevant assets, the measured delivery figure dropped to 29 KiB.

The page was using images around 1600 pixels wide in cards rendered at roughly 300 CSS pixels.

That's wasteful. But serving only a 300-pixel image isn't correct either, because phone screens pack more than one device pixel into each CSS pixel.

Lighthouse's mobile emulation uses a device pixel ratio of 1.75:

300 × 1.75 = 525 device pixels

A 640-pixel image covers that. Many real phones run at a DPR of 2.5 to 3, though:

300 × 3 = 900 device pixels

That's why I generated both 640-pixel and 960-pixel WebP variants. If you need to shrink existing files first, the HeadsNTail image compressor handles that in the browser.

Then used:

<img
  src="/assets/images/article-640.webp"
  srcset="
    /assets/images/article-640.webp 640w,
    /assets/images/article-960.webp 960w
  "
  sizes="
    (max-width: 600px) 92vw,
    (max-width: 1100px) 45vw,
    300px
  "
  width="960"
  height="540"
  loading="lazy"
  decoding="async"
  alt="PHP hosting performance guide"
>

The browser uses sizes to estimate how wide the image will render, then picks a suitable srcset candidate for the screen's DPR.

Without sizes, a width-descriptor srcset assumes the image is 100vw wide. That leads to unnecessarily large downloads for grid cards.

Make the sizes values match your actual CSS layout. Keep width and height on the tag so the browser reserves space and the layout doesn't shift.

Don't use loading="lazy" on the primary LCP image. Reserve it for content below the fold.

Reduce non-composited animations from 38 to zero

Lighthouse also reported 38 elements with non-composited animations.

The affected properties included:

PropertyReported occurrences
box-shadow17
border-color17
width12
filter7

These are diagnostic counts from the audit, not separate groups. One animated element can contribute more than one property.

The problem is that animating these properties forces style recalculation, layout or repainting on every frame.

transform and opacity animations can usually run on the compositor without repainting large regions.

Replace animated width with scaleX

Instead of:

.progress-bar {
  width: 0;
  transition: width 500ms ease;
}

.progress-bar.active {
  width: 100%;
}

Use:

.progress-bar {
  width: 100%;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 500ms ease;
}

.progress-bar.active {
  transform: scaleX(1);
}

The result looks the same, but the browser does far less work per frame.

For cards, don't animate large shadows. Keep the shadow static and animate a small translation instead:

.card {
  box-shadow: 0 6px 18px rgb(0 0 0 / 8%);
  transition: transform 180ms ease;
}

.card:hover {
  transform: translateY(-3px);
}

I also removed unnecessary blur effects on mobile and stopped using backdrop-filter on the header and cards.

Blur looks good in a desktop screenshot but gets expensive on low-powered phones, especially on large scrolling surfaces like a sticky header.

This isn't a rule to never use shadows or filters. Static decorative styles are fine. What costs you is animating these properties continuously.

After these changes, the non-composited animation audit dropped from 38 to zero.

Add a page cache to cut shared hosting TTFB

Once the front-end problems were under control, the server became worth examining.

The audit measured Time to First Byte (TTFB) at around 550 to 780 ms without a page cache.

With the page cache, it was about 300 ms.

That matters on shared hosting, where PHP process startup and other tenants on the server add variability.

For a public content site, the simplest safe approach is to cache the fully rendered HTML for anonymous visitors.

I used a 30-minute lifetime and a cache warmer scheduled through cPanel.

The basic flow:

Anonymous request
       |
       v
Check page cache
       |
       +-- Fresh HTML exists --> Serve cached HTML
       |
       +-- Cache missing -----> Render PHP page
                                  |
                                  v
                               Store HTML
                                  |
                                  v
                              Return page

Example file-based PHP page cache

Save this as page-cache.php and require it at the very top of index.php, before any output.

The draft version of this code bypassed the cache whenever any cookie was present. That sounds safe, but Google Analytics and AdSense set first-party cookies such as _ga on the first visit. Every returning visitor would have skipped the cache.

So bypass only for the cookies that mean a visitor may see personalized HTML:

<?php

if (!function_exists('hnt_has_set_cookie')) {
    function hnt_has_set_cookie(): bool
    {
        foreach (headers_list() as $header) {
            if (stripos($header, 'Set-Cookie:') === 0) {
                return true;
            }
        }

        return false;
    }
}

$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';

// Cookies that mean "this visitor may see personalized HTML".
// Analytics cookies like _ga are deliberately NOT listed.
$privateCookies = ['PHPSESSID', 'remember_me'];

$hasPrivateCookie = false;

foreach ($privateCookies as $name) {
    if (isset($_COOKIE[$name])) {
        $hasPrivateCookie = true;
        break;
    }
}

$eligible =
    $method === 'GET' &&
    !$hasPrivateCookie &&
    empty($_GET) &&
    in_array($path, ['/', '/blog'], true);

if ($eligible) {
    // Keep the cache folder outside public_html so it can't be browsed directly.
    $cacheDir = dirname(__DIR__) . '/storage/page-cache';

    if (!is_dir($cacheDir)) {
        mkdir($cacheDir, 0750, true);
    }

    $file = $cacheDir . '/' . hash('sha256', $path) . '.html';
    $ttl = 1800; // 30 minutes

    if (is_file($file) && time() - filemtime($file) < $ttl) {
        header('Content-Type: text/html; charset=UTF-8');
        header('X-Page-Cache: HIT');
        readfile($file);
        exit;
    }

    header('X-Page-Cache: MISS');

    // Capture the rendered page and store it only if it's a clean, cookie-free 200.
    ob_start(function (string $html) use ($file): string {
        if (
            $html !== '' &&
            http_response_code() === 200 &&
            !hnt_has_set_cookie()
        ) {
            file_put_contents($file, $html, LOCK_EX);
        }

        return $html;
    });
}

Replace the names in $privateCookies with the cookies your site actually uses for sessions and logins.

One common trap: if your public pages call session_start(), PHP sends a Set-Cookie header to every new visitor. The cache then never stores anything. Start sessions only on pages that need them.

How to verify: run this twice and watch the header change from MISS to HIT:

curl -sI https://headsntail.com/ | grep -i x-page-cache

This example is deliberately limited. A production cache should also handle:

  • simultaneous misses
  • redirects
  • language variants
  • invalidation

Don't enable full-page caching globally on a site with logins, account pages, carts or personalized forms.

When you publish or edit an article, delete its cached file and the cached files of any listing pages that show it.

While you're checking response headers, also confirm that HTML, CSS and JS come back compressed (content-encoding: br or gzip). If a security scan flags BREACH, read how to mitigate BREACH without losing compression before you switch compression off.

Warm the cache with cPanel cron

A cache warmer stops public pages from sitting cold until a visitor or crawler happens to request them.

A simple script:

#!/bin/sh

curl -fsS --max-time 20 \
  -o /dev/null \
  https://headsntail.com/

curl -fsS --max-time 20 \
  -o /dev/null \
  https://headsntail.com/blog

Save it outside the public directory, for example:

/home/USERNAME/scripts/warm-cache.sh

In cPanel → Cron Jobs, use this schedule (every 10 minutes):

*/10 * * * *

With this command:

/bin/sh /home/USERNAME/scripts/warm-cache.sh

Replace USERNAME with your actual cPanel account name. Running the script through /bin/sh means you don't need to make it executable. If you want a different interval, the cron job generator builds the expression for you.

curl sends no cookies, so the warmer's requests always count as anonymous and refill the cache.

The warmer runs every 10 minutes and the HTML cache lives for up to 30 minutes. An expired page waits at most 10 minutes before the warmer renders it again.

Warming only the homepage doesn't warm every article. For a larger site, warm a controlled list of public URLs or recently published pages, one request at a time. Don't fire hundreds of concurrent requests at a shared server.

PageSpeed results on shared hosting: before and after

These are measurements from the HeadsNTail homepage on 22 October 2026.

They aren't estimates or numbers from a different hosting environment.

MetricBeforeAfter
Mobile performance score6195
First Contentful Paint3.0 s1.2 s
Largest Contentful Paint6.9 s1.4 s
Total Blocking Time270 ms230 ms
Cumulative Layout Shift0.0940
Speed Index4.8 s2.9 s
Desktop performance score7097

The final LCP of 1.4 seconds came from the combined rendering, carousel and asset fixes.

These are Lighthouse lab results. They don't prove that real-user Core Web Vitals improved by the same amount. For that, check the field data section in PageSpeed Insights or the Core Web Vitals report in Search Console. Both take weeks to reflect a change.

Google's "good" Core Web Vitals thresholds are:

MetricGood threshold
LCP2.5 seconds or less
Interaction to Next Paint (INP)200 ms or less
CLS0.1 or less

These are measured at the 75th percentile of real-user page visits.

Lighthouse's Total Blocking Time is a useful lab diagnostic, but it isn't the same metric as INP.

For the official definitions, see Google's Core Web Vitals documentation.

What still slows the page down: AdSense and Analytics

After the local fixes, third-party scripts were the main source of JavaScript overhead.

The final audit reported 218 KiB of unused JavaScript.

The largest contributors:

ScriptReported unused JavaScript
show_ads_impl120 KiB
gtag.js71 KiB
adsbygoogle.js25 KiB

The long-task report also showed:

SourceLong tasks
Google Analytics140 ms, 100 ms
AdSense91 ms, 88 ms

The individual unused-JavaScript entries are rounded, so their sum won't exactly match the audit total.

These scripts are a real trade-off:

  • Removing AdSense improves performance but removes the ad revenue.
  • Removing Analytics reduces JavaScript work but removes measurement you may depend on.

More reasonable options:

  • load scripts only on the pages that need them
  • check for duplicate tags, such as gtag loaded both directly and through Tag Manager
  • remove third-party integrations you no longer use

Test any delayed-loading strategy against consent requirements, ad behavior, analytics accuracy and the provider's policies.

I wouldn't blindly defer every advertising script just to reach a perfect Lighthouse score. A 95 with working business features is worth more than a 100 with something important broken.

Why your score drops after deployment

A few problems are easy to mistake for a failed optimization.

The first run is slower than the second

The page cache may be cold, PHP may need warming, or external resources may load at different speeds.

Run several tests under the same conditions. Record both the cold and the warmed result. Don't throw the cold result away, because some real users will get it.

The LCP image is preloaded but still appears late

Preloading helps the browser discover the resource earlier. It doesn't guarantee the element paints immediately.

Look at the LCP breakdown:

  • If resource load delay is high: check that the image is in the HTML, not lazy, and that the preload URL matches.
  • If load delay is low but element render delay is high: look at JavaScript rendering, opacity: 0 reveal rules, animations and main-thread work.

Fonts arrive one after another

Check whether your stylesheets reference font weights or families the page doesn't actually use.

A sensible font declaration:

@font-face {
  font-family: "Site Sans";
  src: url("/assets/fonts/site-sans.woff2") format("woff2");
  font-display: swap;
  font-weight: 400 700;
  font-style: normal;
}

The 400 700 weight range only makes sense if the file is a variable font that supports those weights. For static fonts, write one @font-face per file with its single weight.

Preload only the font needed for immediately visible text. Font preloads need crossorigin even on your own domain, or the browser downloads the font twice:

<link
  rel="preload"
  href="/assets/fonts/site-sans.woff2"
  as="font"
  type="font/woff2"
  crossorigin
>

The browser ignores the smaller srcset candidate

Check the card's computed width, the device's DPR and your sizes value.

A 300-CSS-pixel card on a DPR 3 phone should download the 960-pixel image. The browser isn't making a mistake.

The score changes when an extension is active

Disable extensions or test in a clean Incognito window.

My ad-blocker test showed how badly an extension can distort Total Blocking Time. Don't compare a normal browsing session against a clean Lighthouse run as if they were equivalent.

What I'd check before paying for a hosting upgrade

When you see an LCP of nearly seven seconds, the first instinct is to blame shared hosting.

But a server upgrade won't fix any of these:

  • a hero image created late by JavaScript
  • a lazy-loaded LCP image
  • an animation keeping content invisible
  • autoplay changing the LCP candidate

I'd check all of those before paying for more CPU, RAM or a CDN.

A CDN starts to matter when the measured problem is geographic distance, static asset delivery or load on your origin server.

If you run a framework rather than plain PHP, deployment choices affect speed too. The Laravel on cPanel deployment checklist covers config caching and the other steps that matter there.

The most important habit is to change one bottleneck at a time and save the matching Lighthouse JSON. Otherwise it's surprisingly easy to credit the wrong fix.

FAQ

Does the PageSpeed Insights score directly affect Google rankings?

No. The Lighthouse performance score itself isn't a ranking factor. Google's page experience signals use Core Web Vitals measured from real users, and even good Core Web Vitals don't guarantee better rankings. Improve the experience real visitors get rather than chasing a perfect lab score.

Do I need a CDN to get a 90+ PageSpeed score on shared hosting?

No. HeadsNTail reached a mobile score of 95 on Namecheap shared cPanel hosting without a CDN. A CDN can still help with geographic latency and static file delivery. It just shouldn't be your first purchase when rendering delays are the main problem.

Why does PageSpeed give different scores on identical code?

Several things vary between runs:

  • network timing
  • server cache state
  • CPU scheduling
  • third-party scripts
  • which element ends up as the LCP candidate

Carousel autoplay was the biggest source of variation in my tests. Use consistent settings, compare several reports, and read the JSON instead of trusting a single score.

Is full-page caching safe if my website has user logins?

Only if personalized responses never enter the cache. Bypass the cache for any request carrying a session or login cookie, never cache responses that set cookies, and start with anonymous public pages only. Test invalidation before you expand the cache to more URLs.

Free tools for Hosting work

All free tools →

More Hosting articles