What is lazy loading? How deferring resources speeds up the web
Lazy loading is a performance strategy that defers non-critical resources (images, iframes, JavaScript, CSS) until they are needed. Learn what can be lazy-loaded, the native loading attribute, the Intersection Observer API, and when lazy loading helps or hurts.
On this page
- What can be lazy-loaded
- The native loading attribute
- The Intersection Observer API
- Code splitting and dynamic imports
- When lazy loading helps and when it hurts
- FAQ
- What is the difference between loading="lazy" and the Intersection Observer API?
- Does lazy loading hurt SEO?
- Should I lazy-load the first image on a page?
- Can I lazy-load CSS?
- Why does my lazy-loaded image still load immediately?
- Summary
Lazy loading is a performance strategy that defers the loading of non-critical resources until they are actually needed. The web has grown heavier over time: between 2011 and 2019, the median resource weight increased from about 100 KB to 400 KB on desktop and 50 KB to 350 KB on mobile, while image size grew from 250 KB to 900 KB on desktop. Lazy loading helps by shortening the critical rendering path: the browser only fetches what the first viewport needs, then loads the rest on demand. This guide explains what can be lazy-loaded, the native browser mechanisms, the JavaScript APIs that fill the gaps, and the trade-offs to watch for.
What can be lazy-loaded
Lazy loading applies to any resource that is non-critical for the first render. The MDN performance guide groups the candidates into several categories.
| Resource type | Lazy-load mechanism | Typical use case |
|---|---|---|
| JavaScript | type="module" (deferred by default), dynamic import() |
Code that runs only after a user action |
| CSS | Media queries (@media print), preload with onload swap |
Print styles, non-critical theming |
| Fonts | font-display, preloading, Font Loading API |
Decorative webfonts that block initial text |
| Images | loading="lazy" attribute, Intersection Observer |
Below-the-fold photos, galleries |
| iframes | loading="lazy" attribute |
Offscreen video embeds, ads, social widgets |
| Video / audio | preload="none", Intersection Observer |
Players far down the page |
Each of these mechanisms shifts work off the critical path, but only when the resource is truly non-critical for the first paint. Lazy-loading the LCP image, for example, can delay the most important render and hurt perceived performance.
The native loading attribute
Modern browsers ship a built-in lazy-loading mechanism for images and iframes: the loading HTML attribute. It accepts two values: lazy defers the resource until it is near the viewport, while eager (the default) loads it normally. Adding the attribute is enough; no JavaScript library is required, and lazy-loading still works when JavaScript is disabled (though the load is only deferred, not skipped, when JS is off).
<img src="photo.jpg" loading="lazy" alt="…" width="600" height="400">
<iframe src="https://example.com" loading="lazy" width="600" height="400"></iframe>
Browsers determine how far ahead of the viewport to fetch by using an effective-connection-type heuristic. On a slow 2G connection the threshold is tighter (the browser fetches only when the user is very close), while on a fast 4G connection the threshold is much wider, so images are usually already loaded by the time the user reaches them. Chrome's thresholds have been tuned over time; the July 2020 update, for example, widened them on 4G to better match developer expectations.
The loading attribute is independent of fetch priority. An image marked loading="lazy" fetchpriority="high" is still delayed while it is off-screen, then fetched with high priority once it is near the viewport. The combination is rarely needed because the browser would have prioritized that image anyway. For above-the-fold images, use fetchpriority="high" (without loading="lazy") to mark them as the LCP candidate.
The Intersection Observer API
The loading attribute is built on top of the Intersection Observer API, which is the general-purpose primitive for "tell me when this element enters the viewport". Before Intersection Observer, sites had to wire up scroll, resize, and orientationchange event handlers and call getBoundingClientRect() on the main thread for every element being observed. On a long infinite-scroll page with many observers, that work compounded into noticeable jank.
Intersection Observer runs the visibility check off the main thread. The browser fires a callback when an observed element intersects a root element (the viewport by default) or a specified ancestor, or when the intersection ratio crosses a threshold. The API exposes the root, rootMargin (a CSS-like offset to grow or shrink the trigger area), and a list of threshold ratios. A typical lazy-load setup watches an element with a rootMargin of 200px so the image starts loading a little before the user scrolls it into view.
Code splitting and dynamic imports
JavaScript bundles can be split so that only the code needed for the current view is fetched up front. Entry-point splitting separates code by route, while dynamic splitting uses the import() expression to load a module on demand. A practical example is a route that only loads a chart library when the user navigates to a "Reports" tab; users who never visit that tab never download the chart code at all.
Scripts declared with type="module" are deferred by default, which is another form of lazy loading. The browser fetches them in parallel with HTML parsing but only executes them after the document has been parsed. async scripts execute as soon as they finish downloading, which is useful for independent functionality (analytics, ads) but can interfere with the order of execution.
When lazy loading helps and when it hurts
Lazy loading is a net win for resources that are clearly below the fold and unlikely to be needed immediately, but it can backfire on a few patterns. Lazy-loading the LCP image pushes the most important render later, which is the opposite of what you want. Lazy-loading above-the-fold images forces the browser to wait for two fetches (the lazy trigger, then the image) where one eager fetch would have sufficed. Lazy-loading content in a carousel the user is likely to scroll through often wastes time on the first interaction, because the user sees an empty placeholder while the first slide is being fetched.
Always pair loading="lazy" images with explicit width and height attributes (or a CSS aspect-ratio container). Without reserved dimensions, the image slot is zero-sized until the file loads, and the surrounding content snaps downward as the image paints in — a classic source of Cumulative Layout Shift. The same lesson applies to iframes: a width and height (or styled container) prevents the page from jumping when the iframe finally appears.
FAQ
What is the difference between loading="lazy" and the Intersection Observer API?
loading="lazy" is the browser's built-in lazy-loading attribute for images and iframes; the browser handles the visibility detection internally. The Intersection Observer API is a general-purpose JavaScript primitive that lets you detect when any element enters or exits the viewport, which you can use to lazy-load arbitrary resources (background images, video posters, custom markup) and to drive patterns like infinite scroll.
Does lazy loading hurt SEO?
Lazy loading itself does not hurt SEO as long as the content becomes available to crawlers and the page does not have to wait on user interaction. Googlebot renders pages with a headless browser that executes JavaScript, and Google has confirmed that the loading attribute is safe for crawl. The real SEO risk is performance: if lazy-loading the LCP image delays the most important render, the page scores worse on Core Web Vitals and may rank lower. Always keep the LCP image eager.
Should I lazy-load the first image on a page?
No. The first (and largest) image in the initial viewport is typically the LCP element, and delaying it hurts perceived performance. Mark it with fetchpriority="high" and let it load eagerly. Save loading="lazy" for images that are clearly below the fold and unlikely to be visible on first paint.
Can I lazy-load CSS?
Yes, but the common techniques are different from loading="lazy". You can scope non-critical CSS to a media query (@media (min-width: 800px)), preload it with <link rel="stylesheet" media="print" onload="this.media='all'">, or split CSS by route. CSS that is render-blocking for the first paint should not be deferred.
Why does my lazy-loaded image still load immediately?
The browser has already decided the image is close enough to the viewport that deferring would not save anything. The loading attribute is a hint, not a hard promise. On fast connections with a generous rootMargin, the image starts fetching as soon as the parser reaches it.
Summary
Lazy loading defers non-critical resources until they are needed, shrinking the critical rendering path and reducing the data a page consumes on first load. The browser's loading attribute handles images and iframes; the Intersection Observer API covers anything else; import() and code splitting cover JavaScript. Always reserve dimensions on lazy-loaded media to avoid layout shift, and never lazy-load the LCP image. Used carefully, lazy loading is one of the highest-leverage performance techniques on the modern web.
Sources
- MDN — Lazy loading (developer.mozilla.org/en-US/docs/Web/Performance/Guides/Lazy_loading
- CC-BY-SA 2.5+)
- MDN — Intersection Observer API (developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
- CC-BY-SA 2.5+)
- web.dev — Browser-level image lazy loading (web.dev/articles/browser-level-image-lazy-loading
- CC BY 4.0)
- web.dev — Lazy-load offscreen iframes (web.dev/articles/iframe-lazy-loading
- CC BY 4.0)
Share this guide
Related guides
- What is caching? Browser, CDN, server, and database caching explained
How caching works at every layer of the web — browser cache, CDN edge cache, server-side cache, and database cache — and when each one matters for speed and scalability.
- What is code splitting? How it works and when to use it
Code splitting divides a JavaScript bundle into smaller chunks that load on demand, speeding up initial page load. This guide explains the technique, the tools, and when it matters most.
- What are Core Web Vitals? LCP, INP, and CLS explained
Core Web Vitals are a set of Google metrics that measure the real-user experience of a web page: loading, interactivity, and visual stability. Learn what LCP, INP, and CLS measure, their target thresholds, and how to improve them.