Deep technical guide covering native lazy loading, IntersectionObserver fallback, width/height attributes for CLS prevention, aspect-ratio CSS tricks, LQIP placeholders, fetchpriority for LCP images, and JekCMS picture element implementation with AVIF/WebP. Includes real PageSpeed metrics.
The Real Cost of Unoptimized Images
Last November, I audited twelve JekCMS sites that had been running in production for at least three months. Every single one had the same problem: images were the largest contributor to page weight, and most sites were serving full-resolution originals where a 480px-wide thumbnail would have been sufficient. One site — a hobby blog with 340 posts — was loading 8.7MB of images on the homepage because the post cards used the original 4000x3000 JPEG uploads.
After implementing the techniques in this guide across all twelve sites, the average page weight dropped from 4.2MB to 680KB, and the average Largest Contentful Paint improved from 4.1 seconds to 1.6 seconds on mobile. Those are not cherry-picked numbers — that is the median across all twelve.
This guide covers everything we learned about image performance during that optimization sprint, with actual code from JekCMS's implementation.
Native Lazy Loading: The 90% Solution
The loading="lazy" attribute has been supported in Chrome since version 76, Firefox since 75, and Safari since 15.4. That covers roughly 94% of global browser traffic as of early 2026. Adding it to an image tag is trivial:
<img src="/uploads/images/2026/03/post-thumbnail.avif"
alt="Article thumbnail"
width="480" height="300"
loading="lazy">
What this does: the browser defers loading the image until it is within a certain distance of the viewport (typically 1250px in Chrome, though this varies). Images above the fold load immediately. Images further down the page load as the user scrolls toward them.
The critical mistake I see repeatedly: developers adding loading="lazy" to every image on the page, including the hero image and the first visible content image. This actually hurts performance because the browser has to evaluate the element's position before deciding to load it, adding unnecessary delay to your LCP element.
Rule of thumb: Never lazy-load images that are visible in the initial viewport. For a typical blog layout, that means the first 1-3 images should load eagerly (the default), and everything below gets loading="lazy".
The IntersectionObserver Fallback
For the remaining 6% of browsers that do not support native lazy loading (mostly older iOS devices and some embedded webviews), we use IntersectionObserver as a fallback. Here is the implementation from JekCMS's assets/js/lazy-load.js:
// Only activate if native lazy loading is NOT supported
if (!("loading" in HTMLImageElement.prototype)) {
const lazyImages = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
if (img.dataset.srcset) {
img.srcset = img.dataset.srcset;
}
img.removeAttribute('data-src');
observer.unobserve(img);
}
});
}, {
rootMargin: '200px 0px'
});
lazyImages.forEach(img => observer.observe(img));
}
The key detail here is the feature detection on line 2. We check for native support first and only initialize the observer if the browser needs it. This avoids double-loading images in modern browsers that already handle loading="lazy" natively.
For the markup, we use a progressive enhancement pattern:
<img src="/uploads/images/placeholder-16x10.svg"
data-src="/uploads/images/2026/03/actual-image.avif"
alt="Description"
width="480" height="300"
loading="lazy">
Modern browsers ignore data-src and use the native lazy loading on the src. Older browsers get the placeholder SVG initially, and the IntersectionObserver swaps in the real image. The placeholder is a 200-byte inline SVG that matches the aspect ratio.
Width and Height: The CLS Fix That Takes 30 Seconds
Cumulative Layout Shift (CLS) measures how much content moves around as the page loads. Images without explicit dimensions are the number one cause of CLS problems because the browser does not know how much space to reserve until the image downloads and it can read the file headers.
The fix is embarrassingly simple — add width and height attributes to every <img> tag:
<!-- BAD: No dimensions, causes layout shift -->
<img src="photo.avif" alt="Photo">
<!-- GOOD: Browser reserves exact space -->
<img src="photo.avif" alt="Photo" width="800" height="500">
When the browser sees width and height, it calculates the aspect ratio (800:500 = 16:10) and reserves that exact amount of space in the layout before the image loads. No shift.
In JekCMS, the get_featured_image() helper returns the image URL, but the template is responsible for setting dimensions. Here is how we handle it in post cards:
<div class="card-image">
<img src="<?= get_featured_image($post, 'medium') ?>"
alt="<?= htmlspecialchars($post['title']) ?>"
width="480" height="300"
loading="lazy">
</div>
The actual image might be 800x500 or 1200x750, but the width/height attributes define the aspect ratio, not the display size. CSS handles the actual sizing:
.card-image {
aspect-ratio: 16 / 10;
overflow: hidden;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
Aspect-Ratio CSS: The Modern Approach
The aspect-ratio CSS property (supported since Chrome 88, Firefox 89, Safari 15) provides a cleaner way to reserve space for images, especially when you do not know the exact dimensions at render time.
We use this extensively in JekCMS for image containers:
/* Post card images - 16:10 ratio */
.card-image {
aspect-ratio: 16 / 10;
overflow: hidden;
background: #f0f0f0;
}
/* Gallery thumbnails - square */
.gallery-thumb {
aspect-ratio: 1 / 1;
overflow: hidden;
}
/* Hero banner - cinematic ratio */
.hero-image {
aspect-ratio: 21 / 9;
overflow: hidden;
}
/* All image containers */
.card-image img,
.gallery-thumb img,
.hero-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
The aspect-ratio property combined with object-fit: cover means the container always maintains its shape regardless of the actual image dimensions. A portrait photo and a landscape photo both display correctly in the same card layout, and the browser reserves the right amount of space from the first paint.
One gotcha: aspect-ratio is overridden by explicit height. If you set both aspect-ratio: 16/10 and height: 200px, the height wins. Use one or the other.
LQIP: Low-Quality Image Placeholders
LQIP (Low-Quality Image Placeholder) shows a tiny, blurred version of the image while the full version loads. It is a perceptual trick — the user sees something that resembles the final image immediately, making the page feel faster even though the actual load time is the same.
There are three common LQIP approaches:
- Solid color: Extract the dominant color and use it as a background. Simple, adds zero bytes.
- BlurHash: A 20-30 character string that decodes to a blurred gradient. Looks better than solid color but requires JavaScript to decode.
- Tiny image: A 20x12 pixel version of the image, base64-encoded inline. About 200-400 bytes per image.
We chose the solid color approach in JekCMS because it adds zero extra bytes to the HTML and requires no JavaScript. When an image is uploaded, the Media class extracts the dominant color and stores it in the database:
// In Media.php upload handler
$dominantColor = $this->extractDominantColor($tmpPath);
// Returns hex like "#4a7c59"
$db->update('media', [
'dominant_color' => $dominantColor
], 'id = ?', [$mediaId]);
Then in the template:
<div class="card-image"
style="background-color: <?= $post['dominant_color'] ?? '#e5e5e5' ?>">
<img src="<?= get_featured_image($post, 'medium') ?>"
alt="<?= htmlspecialchars($post['title']) ?>"
width="480" height="300"
loading="lazy">
</div>
The background color shows instantly. The image loads over it. No layout shift, no JavaScript dependency, no extra network requests.
fetchpriority: Telling the Browser What Matters
The fetchpriority attribute (supported since Chrome 102, Edge 102, and Safari 17.2) lets you hint to the browser which resources should be downloaded first. For the LCP image — typically the hero image or the first visible post card image — this can shave 200-400ms off the Largest Contentful Paint.
<!-- Hero image: highest priority -->
<img src="/uploads/hero/main-banner.avif"
alt="Site hero"
width="1600" height="600"
fetchpriority="high">
<!-- Below-fold images: lower priority -->
<img src="/uploads/images/2026/03/thumbnail.avif"
alt="Post thumbnail"
width="480" height="300"
loading="lazy"
fetchpriority="low">
In JekCMS, we set fetchpriority="high" on exactly one image per page — the LCP element. On the homepage, that is the featured post image. On a single post page, that is the featured image at the top. On an archive page, that is the first post card image.
The helper function handles this automatically:
function get_image_attributes($post, $size, $isLCP = false) {
$attrs = 'width="480" height="300" ';
if ($isLCP) {
$attrs .= 'fetchpriority="high" ';
} else {
$attrs .= 'loading="lazy" ';
}
return $attrs;
}
The JekCMS Picture Element: AVIF/WebP with Fallback
AVIF typically produces files 30-50% smaller than WebP at equivalent visual quality, and 60-80% smaller than JPEG. But AVIF support is still catching up — Safari only added full support in version 16.1 (late 2022), and some older Android devices do not support it.
Our solution is the <picture> element with multiple sources:
<picture>
<source srcset="/uploads/images/2026/03/photo-medium.avif" type="image/avif">
<source srcset="/uploads/images/2026/03/photo-medium.webp" type="image/webp">
<img src="/uploads/images/2026/03/photo-medium.jpg"
alt="Description"
width="800" height="500"
loading="lazy">
</picture>
The browser picks the first format it supports: AVIF if available, then WebP, then JPEG as the last resort. In practice, about 87% of our traffic gets AVIF, 11% gets WebP, and 2% falls back to JPEG (based on our server logs from February 2026).
JekCMS generates these variants automatically during upload. The Media class creates all three formats and three sizes for each upload, resulting in up to 9 files per uploaded image. That sounds like a lot, but disk space is cheap and bandwidth is expensive.
Here is the helper that generates the picture element:
function get_featured_picture($post, $size = 'medium', $isLCP = false) {
$basePath = $post['featured_image'] ?? '';
if (empty($basePath)) return '';
$pathInfo = pathinfo($basePath);
$base = $pathInfo['dirname'] . '/' . $pathInfo['filename'];
$avif = UPLOADS_URL . '/' . $base . '-' . $size . '.avif';
$webp = UPLOADS_URL . '/' . $base . '-' . $size . '.webp';
$orig = UPLOADS_URL . '/' . $basePath;
$priority = $isLCP ? 'fetchpriority="high"' : 'loading="lazy"';
return sprintf(
'<picture>
<source srcset="%s" type="image/avif">
<source srcset="%s" type="image/webp">
<img src="%s" alt="%s" width="800" height="500" %s>
</picture>',
$avif, $webp, $orig,
htmlspecialchars($post['title'] ?? ''),
$priority
);
}
Measuring the Impact: Real PageSpeed Numbers
Here are the actual before/after metrics from three of the twelve sites we optimized. All tests were run on PageSpeed Insights with the mobile preset.
Site A (News blog, 2800 posts):
- Performance score: 38 to 89
- LCP: 6.2s to 1.8s
- CLS: 0.42 to 0.01
- Page weight: 5.1MB to 720KB
Site B (Hobby blog, 340 posts):
- Performance score: 41 to 92
- LCP: 4.8s to 1.4s
- CLS: 0.31 to 0.00
- Page weight: 8.7MB to 580KB
Site C (Finance blog, 1200 posts):
- Performance score: 52 to 91
- LCP: 3.9s to 1.6s
- CLS: 0.18 to 0.02
- Page weight: 3.4MB to 640KB
The CLS improvements came almost entirely from adding width/height attributes and aspect-ratio CSS. The LCP improvements came from a combination of AVIF conversion, proper lazy loading (not lazy-loading the LCP image), and fetchpriority. The page weight reduction was mostly AVIF conversion — the format difference alone accounts for 60-80% of the file size reduction.
Common Mistakes to Avoid
After optimizing these twelve sites and reviewing dozens of community submissions, here are the mistakes I see most often:
- Lazy-loading the LCP image. This delays your most important visual element. Use
fetchpriority="high"instead. - Using JavaScript-based lazy loading when native works. Extra JavaScript means extra parsing time. Native lazy loading is faster because the browser handles it internally.
- Setting width/height in CSS but not in HTML attributes. The browser needs the HTML attributes to calculate the aspect ratio before CSS loads. Both serve different purposes.
- Serving AVIF without a fallback. About 2% of traffic still cannot decode AVIF. Always include a WebP or JPEG fallback in a
<picture>element. - Using massive images for thumbnails. A 400x250 thumbnail does not need a 4000x2500 source. Use the right size variant — JekCMS generates these automatically.
Image optimization is not glamorous work, but the performance gains are massive and immediately measurable. If you implement everything in this guide, you should see a 40-70% improvement in your Performance score on PageSpeed Insights, depending on how image-heavy your site is.