Lazy loading postpones work until it becomes useful. For an HTML image, loading="lazy" lets the browser defer its request until the image is near the viewport according to the browser’s policy. This can reduce initial transfers on long articles, but the attribute is a scheduling input, not a guarantee that every unseen image remains undownloaded.

Let the browser schedule ordinary images

Native lazy loading uses the real src or srcset, so ordinary image markup remains understandable without a custom scroll handler. The browser chooses its loading distance and may adapt that decision to implementation and connection conditions. Authors do not set a native distance threshold through the loading attribute. An IntersectionObserver implementation has different controls and additional application responsibility; it is not required merely to use native loading.

Keep images needed for the first view eager, especially an important hero image. Deferring an image that determines the largest visible content can delay useful rendering. Conversely, preloading a deliberately deferred image can undermine the intended transfer saving. Decide from the image’s role and measured behavior rather than assigning the same loading policy to every asset on the page.

Specify intrinsic width and height so the browser can reserve an aspect ratio before bytes arrive. Responsive CSS can still reduce the rendered width and maintain height automatically. Without useful dimensions, images may occupy little or no space before loading, change which content appears near the viewport, and move text when they arrive. Correct alt text also matters when an image cannot be displayed.

When it breaks

Testing only the initial load event can miss deferred images. The page may finish its load event while a distant lazy image has not completed. Inspect individual image completion, natural dimensions, requests, and actual rendering after scrolling. A request failure and a request deliberately not started are different states, even if both initially show no decoded pixels.

A page with no custom JavaScript can use native lazy loading, but MDN documents that deferral requires scripting to be enabled in the browser as an anti-tracking measure. Disabling JavaScript is therefore not equivalent to merely removing application scripts. Validate the environments your site supports and avoid claiming identical request savings in every configuration.

The attribute applies to supported elements, not arbitrary CSS background images. Likewise, content-visibility can skip rendering work without promising that all associated network requests are deferred. For fast scrolling on a slow connection, native loading can still reveal a placeholder before the image arrives. Inspect visible gaps and layout stability, not just a smaller initial request count.

Check it quickly

Save the following as lazy-images.py and run python3 lazy-images.py. The Python 3 command verifies that the local page and both images are available. Its output explicitly reports native_loading_tested_by_cli false, because an HTTP client fetching markup does not execute a browser’s image scheduler.

from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Event, Lock, Thread
import json
import sys

requests = []
lock = Lock()
page = '''<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Native image loading</title><link rel="icon" href="data:,"><style>body{font:18px sans-serif;margin:24px;max-width:700px}img{max-width:100%;height:auto}.gap{height:60000px}</style></head><body><h1>Native image loading</h1><p>The first image loads eagerly. Scroll down to reach the reserved lazy image.</p><img id="first" src="/eager.svg" width="480" height="160" alt="Eager example"><div class="gap"></div><h2>Deferred image</h2><img id="late" src="/late.svg" loading="lazy" width="480" height="160" alt="Lazy example"><p>Both image dimensions were reserved before loading.</p></body></html>'''


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/':
            data, kind = page.encode(), 'text/html; charset=utf-8'
        elif self.path in ('/eager.svg', '/late.svg'):
            with lock:
                requests.append(self.path)
            color = '#2563eb' if self.path == '/eager.svg' else '#047857'
            label = 'EAGER' if self.path == '/eager.svg' else 'LAZY'
            data = f'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="160"><rect width="480" height="160" fill="{color}"/><text x="24" y="92" font-family="sans-serif" font-size="36" fill="white">{label}</text></svg>'.encode()
            kind = 'image/svg+xml'
        elif self.path == '/events':
            with lock:
                data = json.dumps(requests).encode()
            kind = 'application/json'
        else:
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header('Content-Type', kind)
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *args):
        pass


server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = Thread(target=server.serve_forever)
thread.start()
try:
    if '--serve' in sys.argv:
        print(json.dumps({'origin': f'http://127.0.0.1:{server.server_port}'}), flush=True)
        try:
            Event().wait()
        except KeyboardInterrupt:
            pass
    else:
        for path in ('/', '/eager.svg', '/late.svg'):
            connection = HTTPConnection('127.0.0.1', server.server_port, timeout=5)
            try:
                connection.request('GET', path)
                response = connection.getresponse()
                data = response.read()
                assert response.status == 200
                if path == '/':
                    assert b'loading="lazy"' in data and b'height:60000px' in data
            finally:
                connection.close()
        print(json.dumps({'page_and_assets_available': True, 'native_loading_tested_by_cli': False}))
finally:
    server.shutdown()
    thread.join()
    server.server_close()

Next run python3 lazy-images.py --serve and open the printed origin in a fresh browser profile with scripting enabled. The page itself contains no JavaScript. It places an eager image near the heading and a lazy image after a deliberately exaggerated 60,000-pixel gap. Inspect /events before scrolling, then scroll to the Deferred image heading and inspect again. Stop the server with Ctrl-C.

In Chrome 152.0.7977.83, both a 1440×1000 desktop viewport and a 390×844 mobile viewport initially requested only eager.svg. The distant image had naturalWidth zero while reserving 480×160 and 342×114 rendered space respectively. After scrolling, late.svg was requested once, naturalWidth became 480, and the reserved sizes stayed unchanged. The large gap makes this a scheduling demonstration, not a realistic page layout or performance benchmark. No disabled-scripting case was measured. Repeat the scroll check under your actual layouts and connection conditions before attributing a user-visible improvement to the attribute.

Sources