Web Performance & Standards

What is lazy loading?

Lazy loading is a web performance technique that defers the loading of non-critical resources, images, videos, iframes, and JavaScript modules, until they are actually needed rather than loading everything simultaneously when the page first loads. Resources that are not immediately visible to the user, below the fold, outside the current viewport, or not needed for initial interaction, are withheld from loading until the user scrolls them into view or explicitly triggers them. The result is a faster initial page load, fewer resources compete for bandwidth and processing during the critical loading window, and reduced overall bandwidth consumption when users do not scroll through the entire page.

The name captures the core philosophy, rather than eagerly loading everything as soon as possible the browser lazily defers loading until necessity demands it. This deferred approach contrasts with the traditional eager loading model, where all images and resources referenced in the HTML are fetched regardless of whether the user will ever see them.

Lazy loading is implemented through several mechanisms, the native HTML loading attribute for images and iframes, the Intersection Observer API for JavaScript-based lazy loading, and dynamic import for JavaScript module lazy loading. Each mechanism serves different resource types and provides different levels of control over when deferred loading is triggered.

For page speed and Core Web Vitals lazy loading has both positive and negative effects depending on how it is applied. Correctly applied lazy loading improves initial load performance by reducing the resources competing for bandwidth during the critical loading window. Incorrectly applied, particularly when applied to the LCP image, lazy loading directly degrades LCP by deliberately delaying the loading of the most important visible content element.

How lazy loading works

Lazy loading operates by replacing immediate resource loading with deferred loading triggered by proximity to the viewport or explicit user actions.

The viewport and fold concept, the viewport is the visible area of the browser window, the content the user can see without scrolling. The fold, a metaphor from newspaper publishing, is the boundary between visible and non-visible content. Content above the fold is immediately visible. Content below the fold requires scrolling to see. Lazy loading defers resources below the fold, loading them only when the user scrolls them into proximity with the viewport.

Native HTML lazy loading, the loading="lazy" attribute on <img> and <iframe> elements instructs the browser to defer loading until the element is near the viewport:

<!-- Eagerly loaded — browser fetches immediately -->
<img src="/hero-image.webp" alt="Hero image" width="1200" height="600">

<!-- Lazy loaded — browser defers until near viewport -->
<img src="/product-thumbnail.webp" 
     alt="Product" 
     width="300" 
     height="300" 
     loading="lazy">
<!-- Eagerly loaded — browser fetches immediately -->
<img src="/hero-image.webp" alt="Hero image" width="1200" height="600">

<!-- Lazy loaded — browser defers until near viewport -->
<img src="/product-thumbnail.webp" 
     alt="Product" 
     width="300" 
     height="300" 
     loading="lazy">
<!-- Eagerly loaded — browser fetches immediately -->
<img src="/hero-image.webp" alt="Hero image" width="1200" height="600">

<!-- Lazy loaded — browser defers until near viewport -->
<img src="/product-thumbnail.webp" 
     alt="Product" 
     width="300" 
     height="300" 
     loading="lazy">

The browser determines when to begin loading a lazy image based on an internal threshold, typically when the element is within a certain distance from the viewport, the threshold distance varies by browser and connection speed. On slower connections browsers load lazy images earlier, further from the viewport, to reduce the likelihood of visible loading delays as the user scrolls.

Intersection Observer API, a JavaScript API that enables efficient detection of when elements enter or exit the viewport, providing the foundation for custom lazy loading implementations that go beyond what native loading="lazy" supports.

// Custom lazy loading with Intersection Observer
const lazyImages = document.querySelectorAll('img[data-src]')

const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target
            img.src = img.dataset.src
            img.removeAttribute('data-src')
            observer.unobserve(img)
        }
    })
}, {
    // Begin loading when image is 200px from viewport
    rootMargin: '200px'
})

lazyImages.forEach(img => imageObserver.observe(img))
// Custom lazy loading with Intersection Observer
const lazyImages = document.querySelectorAll('img[data-src]')

const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target
            img.src = img.dataset.src
            img.removeAttribute('data-src')
            observer.unobserve(img)
        }
    })
}, {
    // Begin loading when image is 200px from viewport
    rootMargin: '200px'
})

lazyImages.forEach(img => imageObserver.observe(img))
// Custom lazy loading with Intersection Observer
const lazyImages = document.querySelectorAll('img[data-src]')

const imageObserver = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            const img = entry.target
            img.src = img.dataset.src
            img.removeAttribute('data-src')
            observer.unobserve(img)
        }
    })
}, {
    // Begin loading when image is 200px from viewport
    rootMargin: '200px'
})

lazyImages.forEach(img => imageObserver.observe(img))

The rootMargin parameter specifies how far from the viewport the element must be before the observer fires, providing control over how eagerly images load relative to the scroll position.

Dynamic JavaScript imports, JavaScript module lazy loading using dynamic import() syntax, deferring the loading of JavaScript code until it is actually needed:

// Eager import — bundle includes this module always
import { heavyChart } from './chart-library.js'

// Lazy import — module only loads when this code executes
async function showChart() {
    const { heavyChart } = await import('./chart-library.js')
    heavyChart.render()
}

// Load chart module only when user clicks chart button
document.querySelector('#chart-button').addEventListener('click', showChart)
// Eager import — bundle includes this module always
import { heavyChart } from './chart-library.js'

// Lazy import — module only loads when this code executes
async function showChart() {
    const { heavyChart } = await import('./chart-library.js')
    heavyChart.render()
}

// Load chart module only when user clicks chart button
document.querySelector('#chart-button').addEventListener('click', showChart)
// Eager import — bundle includes this module always
import { heavyChart } from './chart-library.js'

// Lazy import — module only loads when this code executes
async function showChart() {
    const { heavyChart } = await import('./chart-library.js')
    heavyChart.render()
}

// Load chart module only when user clicks chart button
document.querySelector('#chart-button').addEventListener('click', showChart)

Dynamic imports enable code splitting, breaking large JavaScript bundles into smaller modules that load on demand, reducing the JavaScript that must be downloaded and parsed during initial page load.

What to lazy load, and what not to

The most important lazy loading decision is identifying which resources should be lazily loaded and which should be eagerly loaded, the wrong choice for critical resources directly degrades performance metrics.

Resources that should be lazy loaded:

Below-the-fold images, images not visible in the initial viewport. Product thumbnails in a grid below the fold, images in long article bodies, gallery images after the first few. Lazy loading these defers their network requests until the user actually needs them, reducing bandwidth consumption and speeding up initial page load.

Offscreen iframes, embedded maps, social media embeds, video players, and other iframes not visible in the initial viewport. Iframes often load significant resources, JavaScript, CSS, additional network requests, that are unnecessary if the user never scrolls to them.

JavaScript modules for non-critical features, chart libraries, data visualisation tools, complex form validation, animation libraries, that are only needed for specific user interactions. Loading these modules only when the user triggers the relevant feature avoids loading unnecessary code during initial page load.

Videos, video elements with poster images can be lazy loaded, deferring the download of video files until the user interacts with the video player.

Resources that must NOT be lazy loaded:

The LCP element, the single most critical lazy loading mistake. Applying loading="lazy" to the image that will be the LCP element deliberately delays the loading of the most important visible content, directly causing poor LCP. The LCP image must be eagerly loaded, ideally preloaded with fetchpriority="high".

<!-- WRONG — lazy loading the LCP image causes poor LCP -->
<img src="/hero-image.webp" alt="Hero" loading="lazy">

<!-- CORRECT — eagerly load the LCP image -->
<img src="/hero-image.webp" alt="Hero" loading="eager">

<!-- BETTER — preload the LCP image for even faster loading -->
<link rel="preload" as="image" href="/hero-image.webp" fetchpriority="high">
<img src="/hero-image.webp" alt="Hero">
<!-- WRONG — lazy loading the LCP image causes poor LCP -->
<img src="/hero-image.webp" alt="Hero" loading="lazy">

<!-- CORRECT — eagerly load the LCP image -->
<img src="/hero-image.webp" alt="Hero" loading="eager">

<!-- BETTER — preload the LCP image for even faster loading -->
<link rel="preload" as="image" href="/hero-image.webp" fetchpriority="high">
<img src="/hero-image.webp" alt="Hero">
<!-- WRONG — lazy loading the LCP image causes poor LCP -->
<img src="/hero-image.webp" alt="Hero" loading="lazy">

<!-- CORRECT — eagerly load the LCP image -->
<img src="/hero-image.webp" alt="Hero" loading="eager">

<!-- BETTER — preload the LCP image for even faster loading -->
<link rel="preload" as="image" href="/hero-image.webp" fetchpriority="high">
<img src="/hero-image.webp" alt="Hero">

Above-the-fold images, any image visible in the initial viewport without scrolling should be eagerly loaded. Lazy loading above-the-fold images causes visible loading delays, users see placeholder areas rather than images during the loading sequence.

Critical JavaScript, JavaScript needed for page functionality that users engage with immediately should not be deferred. Login forms, navigation menus, and other above-the-fold interactive elements need their JavaScript available immediately.

Lazy loading and Core Web Vitals

Lazy loading has specific and sometimes counterintuitive effects on Core Web Vitals, the relationship varies by metric and by which resources are lazily loaded.

LCP, can improve or degrade depending on application:

Lazy loading below-the-fold images, improves LCP by reducing network contention. Fewer images competing for bandwidth means the LCP image downloads faster, improving LCP.

Lazy loading the LCP image, directly degrades LCP. The browser explicitly defers loading the most important visible element, LCP cannot be good when the LCP resource load is deliberately delayed. This is the single most common LCP performance mistake in modern web development.

Google’s Lighthouse reports an explicit warning when it detects lazy loading applied to the LCP element, identifying it as an Avoid lazy loading images that are in the initial viewport finding.

CLS, potential negative impact:

Lazy loaded images without declared dimensions can cause significant CLS. When a lazy image loads, triggered by user scroll, it may push surrounding content down if no space was reserved for it. The fix is the same as for all images, always declare width and height attributes on lazy loaded images to reserve space before they load:

<!-- Can cause CLS — no dimensions declared on lazy image -->
<img src="/product.jpg" loading="lazy" alt="Product">

<!-- Correct — dimensions declared prevent CLS -->
<img src="/product.jpg" 
     loading="lazy" 
     alt="Product"
     width="300" 
     height="300">
<!-- Can cause CLS — no dimensions declared on lazy image -->
<img src="/product.jpg" loading="lazy" alt="Product">

<!-- Correct — dimensions declared prevent CLS -->
<img src="/product.jpg" 
     loading="lazy" 
     alt="Product"
     width="300" 
     height="300">
<!-- Can cause CLS — no dimensions declared on lazy image -->
<img src="/product.jpg" loading="lazy" alt="Product">

<!-- Correct — dimensions declared prevent CLS -->
<img src="/product.jpg" 
     loading="lazy" 
     alt="Product"
     width="300" 
     height="300">

INP, minimal direct impact:

Lazy loading has minimal direct impact on INP, interaction responsiveness is primarily affected by JavaScript execution rather than image loading. JavaScript module lazy loading can improve INP, deferring non-critical JavaScript modules reduces the JavaScript executing during initial load, reducing long tasks that create input delay.

Native lazy loading behaviour

The HTML loading="lazy" attribute is supported across all modern browsers, but has specific behaviours worth understanding.

Loading threshold, browsers do not wait until an element enters the viewport before loading it. A threshold distance is used, images begin loading before they enter the viewport, typically when they are within 1,250 pixels to 2,500 pixels of the viewport depending on the browser, connection type, and scroll position. This threshold prevents visible loading gaps as the user scrolls, images load slightly ahead of when the user scrolls to them.

The threshold distance varies by connection speed, slower connections trigger loading earlier, because more lead time is needed to finish downloading images before they become visible.

LCP detection issue, the browser determines the LCP element after it begins loading page content. When loading="lazy" is applied to an image that turns out to be the LCP element the browser has already committed to lazy loading it, the delay is incurred. This is why explicitly applying loading="eager", or simply omitting the loading attribute, to images that might be the LCP element is important.

SEO indexing, Googlebot renders pages with JavaScript and supports lazy loading, it scrolls the page during rendering to trigger lazy-loaded content. Images loaded through native lazy loading or JavaScript-based lazy loading are generally indexed by Google. However images that require user interaction, click to load, may not be indexed if Googlebot does not simulate that interaction.

JavaScript module lazy loading

Code splitting and lazy module loading are important techniques for improving page speed by reducing the JavaScript downloaded during initial page load.

Route-based code splitting, in single-page applications loading only the JavaScript for the current route rather than all routes simultaneously. React, Vue, and other frameworks support route-level code splitting through dynamic imports, users download only the code for the pages they visit rather than the entire application upfront.

// React lazy route loading example
import { lazy, Suspense } from 'react'

const CheckoutPage = lazy(() => import('./CheckoutPage'))
const ProductPage = lazy(() => import('./ProductPage'))

function App() {
    return (
        <Suspense fallback={<div>Loading...</div>}>
            <Routes>
                <Route path="/checkout" element={<CheckoutPage />} />
                <Route path="/products/:id" element={<ProductPage />} />
            </Routes>
        </Suspense>
    )
}
// React lazy route loading example
import { lazy, Suspense } from 'react'

const CheckoutPage = lazy(() => import('./CheckoutPage'))
const ProductPage = lazy(() => import('./ProductPage'))

function App() {
    return (
        <Suspense fallback={<div>Loading...</div>}>
            <Routes>
                <Route path="/checkout" element={<CheckoutPage />} />
                <Route path="/products/:id" element={<ProductPage />} />
            </Routes>
        </Suspense>
    )
}
// React lazy route loading example
import { lazy, Suspense } from 'react'

const CheckoutPage = lazy(() => import('./CheckoutPage'))
const ProductPage = lazy(() => import('./ProductPage'))

function App() {
    return (
        <Suspense fallback={<div>Loading...</div>}>
            <Routes>
                <Route path="/checkout" element={<CheckoutPage />} />
                <Route path="/products/:id" element={<ProductPage />} />
            </Routes>
        </Suspense>
    )
}

Component-based code splitting, loading JavaScript for specific components only when they are rendered, reducing initial JavaScript payload for pages that conditionally show complex components.

Library lazy loading, loading large third-party libraries only when features that require them are activated:

// Load chart library only when chart is requested
document.querySelector('#show-chart').addEventListener('click', async () => {
    const { Chart } = await import('chart.js')
    new Chart(canvas, chartConfig)
})
// Load chart library only when chart is requested
document.querySelector('#show-chart').addEventListener('click', async () => {
    const { Chart } = await import('chart.js')
    new Chart(canvas, chartConfig)
})
// Load chart library only when chart is requested
document.querySelector('#show-chart').addEventListener('click', async () => {
    const { Chart } = await import('chart.js')
    new Chart(canvas, chartConfig)
})

Lazy loading and redirect management

Lazy loading has minimal direct interaction with redirect management, redirects are a pre-load event and lazy loading is a post-load behaviour. However a few indirect relationships are worth understanding.

Lazy loading on redirect destination pages, pages reached through redirects benefit from correct lazy loading implementation in the same way as directly accessed pages. The redirect adds TTFB overhead before the page begins loading, correct lazy loading of below-the-fold resources and eager loading of above-the-fold resources minimises the total load time after the redirect.

LCP on destination pages, for pages commonly accessed through redirects ensuring the LCP image is not lazy loaded is particularly important. The redirect already adds TTFB overhead, lazy loading the LCP image on top of redirect overhead can push LCP well into poor territory. Auditing LCP images on redirect destination pages and ensuring they are eagerly loaded, ideally preloaded, partially offsets the redirect’s performance cost.

Related terms

Related terms

Ready to keep every link alive?

Ready to keep every link alive?

Ready to keep every link alive?