A lot of small business sites live on the cheapest shared hosting plan the owner could find in 2015. No SSH, no CDN config, a control panel with a file manager, and maybe a WordPress install with a page builder on top. Then the owner asks why the site feels slow on their phone.
The good news: on most of these sites, the server isn't the main problem. The page is. A 4 MB hero photo, a five-slide carousel, three font families and a chat widget will make any host look bad. You can fix nearly all of that without touching the server, and the few server-side fixes that help usually come down to a handful of lines in .htaccess.
This is the order we work in when we speed up a site like this, with the code for each step.
Measure first, with the right numbers
Run the homepage through PageSpeed Insights and read the two halves separately. The top part is field data from real Chrome users, shown only if the site has enough traffic. The diagnostics below it come from a single simulated lab load. Small business sites often have no field data at all, so you'll be working from the lab run and from DevTools on a real phone.
The targets are Google's Core Web Vitals, measured at the 75th percentile of page loads:
| Metric | What it measures | Good | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | When the main content shows up | 2.5 s or less | over 4 s |
| INP (Interaction to Next Paint) | How fast the page responds to taps | 200 ms or less | over 500 ms |
| CLS (Cumulative Layout Shift) | How much things jump around | 0.1 or less | over 0.25 |
Anything between "good" and "poor" counts as needs improvement. Write down the starting numbers and screenshot the waterfall in DevTools. You'll want both when you show the owner what changed.
1. Images: almost always the biggest win
Open the Network tab, filter by Img, and sort by size. On the sites we look at, the top of that list is nearly always a photo uploaded straight from a camera or phone and displayed at a fraction of its size.
The fix has three parts.
Resize to the display size. A full-width hero doesn't need the 6000px original. We generate three widths and let the browser pick; around 2000px is plenty for the largest screens these sites see. If you have ImageMagick locally:
# One source photo -> three widths, WebP at quality 78
for w in 800 1400 2000; do
magick hero-original.jpg -resize ${w}x -strip -quality 78 hero-${w}.webp
done
Serve modern formats with a fallback, and give the browser width and height so it can reserve space before the image arrives. That's the CLS part.
<picture>
<source type="image/avif"
srcset="/img/hero-800.avif 800w, /img/hero-1400.avif 1400w, /img/hero-2000.avif 2000w"
sizes="100vw">
<source type="image/webp"
srcset="/img/hero-800.webp 800w, /img/hero-1400.webp 1400w, /img/hero-2000.webp 2000w"
sizes="100vw">
<img src="/img/hero-1400.jpg" alt="Technician repairing an air conditioner outside a house"
width="1400" height="788" fetchpriority="high" decoding="async">
</picture>
Lazy-load below the fold, never above it. loading="lazy" is supported in all the major browsers, and the ones that don't support it just ignore it. But web.dev is explicit: "Don't lazy-load images that are likely to be in-viewport when the page loads, especially LCP images." Page builders and some WordPress setups add loading="lazy" to every image, including the hero. Check the hero's markup and remove it there. fetchpriority="high" on the hero is supported in current Chrome, Edge, Firefox and Safari.
<!-- Gallery further down the page -->
<img src="/img/job-03-600.webp" alt="New kitchen sink installed under a window"
width="600" height="400" loading="lazy" decoding="async">
On WordPress you usually don't write this markup yourself, but you can still replace the uploaded originals with properly sized files and check what the theme outputs.
2. Remove the slider and the autoplay video
We have a strong opinion here: on a local business homepage, the carousel should go. It loads several large images to show one, it often depends on a jQuery plugin, and the slides people never see carry the messages the owner cared most about.
Replace it with one image and one sentence. If the owner wants to keep the other slides' content, turn them into short sections lower on the page.
Autoplay background video is the same story with a bigger file. If there's a video worth keeping, use a facade: a static poster image with a play button that loads the real player only when tapped. Chrome's Lighthouse docs describe exactly this pattern and show a YouTube facade at about 3 KB against roughly 540 KB for the full player.
<button class="yt" data-id="VIDEO_ID" aria-label="Play video: a tour of our workshop">
<img src="/img/video-poster.webp" alt="" width="1280" height="720" loading="lazy">
</button>
<script>
document.querySelectorAll('.yt').forEach((btn) => {
btn.addEventListener('click', () => {
const iframe = document.createElement('iframe');
iframe.src = `https://www.youtube-nocookie.com/embed/${btn.dataset.id}?autoplay=1`;
iframe.allow = 'autoplay; encrypted-media; picture-in-picture';
iframe.allowFullscreen = true;
iframe.width = 1280;
iframe.height = 720;
iframe.title = btn.getAttribute('aria-label');
btn.replaceWith(iframe);
}, { once: true });
});
</script>
The same goes for a Google Maps embed in the footer. At minimum add loading="lazy" to the iframe so it only loads when someone scrolls near it. Often a static map image linking to directions is all the page needs.
3. Fonts: fewer files, swap, WOFF2
Check how many font files the page requests. Page builders make it easy to end up with three families in four weights each, and every one is a separate download.
web.dev's font guidance boils down to a few rules we follow:
- "The fastest font to deliver is a font that isn't requested in the first place." System fonts work well for body text.
- Use WOFF2 only.
- Use
font-display: swapso text shows immediately in a fallback font. - Self-hosting isn't automatically faster. web.dev notes the difference against a font service is "less clear cut" than people assume, especially without a CDN and HTTP/2.
On a shared host, our default is: one brand font for headings in one or two weights, system fonts for body.
@font-face {
font-family: "Brand";
src: url("/fonts/brand-700.woff2") format("woff2");
font-weight: 700;
font-display: swap;
}
h1, h2, h3 { font-family: "Brand", system-ui, sans-serif; }
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
4. Third-party scripts: audit, delete, defer
This is where INP problems usually come from on small sites. In DevTools, open the Network tab, sort by domain, and list every third party. For each one, ask the owner one question: do you still use this?
You'll find old analytics tags, an ad pixel from a campaign that ended years ago, a review widget that loads its own copy of jQuery, a cookie banner and a chat widget. Some of these can simply go.
For the ones that stay:
- Add
deferto scripts that don't need to run before the page renders. - Load chat widgets on interaction instead of on page load (the Lighthouse facade docs list chat widgets as a good fit for the same pattern).
<button id="chat-open" class="chat-bubble" aria-label="Open chat">Chat with us</button>
<script>
document.getElementById('chat-open').addEventListener('click', () => {
const s = document.createElement('script');
s.src = 'https://chat-vendor.example/widget.js'; // the vendor's real loader URL
s.async = true;
document.head.appendChild(s);
}, { once: true });
</script>
Check the vendor's docs for how to open the widget once it has loaded, since each one does it differently.
5. Caching and compression in .htaccess
If the host runs Apache or LiteSpeed (check the Server response header, or ask support), you can usually set caching and compression from .htaccess in the site root. web.dev's HTTP caching guide recommends a long max-age (one year, 31536000 seconds) for files whose URLs change when their contents change, and no-cache for files that should be revalidated on every use, which is what you want for HTML.
# Compression (if mod_deflate is available)
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript image/svg+xml
</IfModule>
# Caching
<IfModule mod_headers.c>
# HTML: always revalidate
<FilesMatch "\.html?$">
Header set Cache-Control "no-cache"
</FilesMatch>
# Images and fonts: these rarely change, cache for 30 days
<FilesMatch "\.(avif|webp|jpe?g|png|svg|woff2)$">
Header set Cache-Control "public, max-age=2592000"
</FilesMatch>
# CSS/JS: one year ONLY if filenames are versioned (site.3f9a1.css or ?v=12)
<FilesMatch "\.(css|js)$">
Header set Cache-Control "public, max-age=31536000, immutable"
</FilesMatch>
</IfModule>
Be careful with that last block. If the site's CSS is just style.css with no version in the name, a one-year cache means returning visitors won't see your changes. Either add a version string when you deploy or use a shorter max-age.
Keep a copy of the original .htaccess before editing. A typo here can take the whole site down with a 500 error, and on shared hosting the file manager is often your only way back in.
If the site is WordPress, a page caching plugin that serves static HTML usually does more for server response time than anything else you can do on a shared plan. Pick one, configure it, and don't stack two.
What changes, roughly
Every site is different, so we won't promise numbers. Here is how the fixes map onto the metrics, and an illustrative before/after to show the shape of the change, not a real measurement:
| Fix | Mostly helps | Typical effort |
|---|---|---|
| Resize + convert images | LCP | 30 to 60 min |
| width/height on images | CLS | 15 min |
| Remove slider / autoplay video | LCP, INP | 20 min |
| Trim fonts, add swap | LCP, CLS | 20 min |
| Remove or defer third-party scripts | INP | 30 min + owner check |
| Cache headers + compression | Repeat visits, TTFB | 15 min |
When you report back, keep it in the owner's terms. A fictional example:
The homepage was loading a 4 MB photo and four slides nobody scrolled through. It's now one image at the right size, and the page shows up about twice as fast on my phone over 4G. I also removed two old tracking scripts from an ad campaign that ended. Nothing you use was touched.
Only write that kind of message with your own real before/after numbers in it, and only say something got faster if your measurements show it.
Finding sites that need this
Most of the sites that need this work are easy to spot by hand: search a trade and a town, open the results on your phone over cellular, and note which ones take ages to show anything. PageSpeed Insights will confirm it in a minute each.
If you want a list rather than a tab marathon, LeadReef finds local businesses whose websites are outdated, broken on mobile or missing, shows what's wrong with each one, and helps with a first mockup and outreach message. You pick who to contact and send it yourself. The first 10 leads are free. Full disclosure: we build LeadReef. Everything above works without it.
Sources: web.dev, Web Vitals and Defining the Core Web Vitals metrics thresholds; web.dev, Browser-level image lazy loading; web.dev, It's time to lazy-load offscreen iframes; web.dev, Optimize resource loading with the Fetch Priority API; web.dev, Best practices for fonts; web.dev, Prevent unnecessary network requests with the HTTP Cache; Chrome for Developers, Lazy load third-party resources with facades.



Top comments (0)