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:

Setting Value
Emulated device Moto G Power
Network Slow 4G (simulated)
Device pixel ratio 1.75
Test target HeadsNTail homepage
Hosting Namecheap shared cPanel
Application Plain PHP
CDN None

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.

Fix the LCP element first: the JavaScript carousel

The original hero carousel was built entirely in JavaScript.

Conceptually, it worked like this:

<div id="hero-carousel"></div> <script src="https://headsntail.com/assets/js/home.js" defer></script> 

Then JavaScript created the slides:

const carousel = document.querySelector('#hero-carousel'); carousel.innerHTML = ` <div class="slide"> <img src="https://headsntail.com/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 component Measured delay
Resource load delay 715 ms
Element render delay 640 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 location Measured time
polish.js:35 36 ms
home.js:523 31 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="https://headsntail.com/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:

Property Reported occurrences
box-shadow 17
border-color 17
width 12
filter 7

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;Source:https://dev.to/faizan050/improve-pagespeed-score-on-shared-hosting-61-to-95-4bh

Related posts