# Lazy Loading Explained

> Why deferring work until it's actually needed is one of the cheapest performance wins available, and where it stops paying off.


---

# Lazy Loading Explained

You open a long page with forty images, a chat widget, an analytics script, and a video player near the bottom - if the browser fetched all of it the instant you arrived, you'd wait for things you might never scroll to. Lazy loading is the fix: don't do the work until something proves it's needed. This guide covers the idea, the three places you'll use it constantly, and the tradeoff that keeps it from being a free lunch.

## How to read this

Phase 1 builds the mental model - the difference between eager and lazy, and why "load everything now" is the default that quietly costs you. Phase 2 is where you'll actually use it day to day: images, route-based code, and "load more" patterns. Phase 3 is the clear-eyed tradeoff - what lazy loading can break, and when loading eagerly is the better call.

## The phases

1. [Don't do work nobody asked for yet](01-dont-do-work-nobody-asked-for.md) - the general principle, contrasted with eager loading.
2. [Where you'll actually use it](02-where-youll-use-it.md) - images, code-splitting, and infinite scroll.
3. [The tradeoff](03-the-tradeoff.md) - layout shift, missing content, and when eager wins.


---

# Don't do work nobody asked for yet

Here's the default most software falls into without anyone deciding it on purpose: when the page loads, fetch everything it could ever need - every image, script, and chunk of data, all up front, before the user scrolls a pixel or clicks a button. This is called **eager loading**, and it has one real virtue: it's simple to reason about. Everything is just... there.

The problem is that "could ever need" and "will actually use" are very different sets. A product page might have twenty photos but the visitor reads three and leaves; a dashboard might have six tabs but a session only opens one. Fetching all twenty photos and all six tabs' worth of code is work spent on a bet that mostly doesn't pay off.

**Lazy loading** flips the default: defer the work until something concrete proves it's needed - the element scrolls into view, the route gets visited, the button gets clicked. Nobody asked for the twentieth photo yet, so don't fetch it yet.

> The question lazy loading always asks is: has anyone actually asked for this, or are we guessing they might?

## Eager vs. lazy, side by side

Picture a page with a hero image at the top and nine more images stacked below it, off-screen.

```text
eager:  fetch all 10 images the instant the page starts loading
        -> user sees the hero after all 10 downloads compete for bandwidth

lazy:   fetch the hero image now; fetch each of the other 9
        only as the user scrolls close to it
        -> user sees the hero almost immediately, nothing else competes
```

*What just happened:* in the eager version, the browser doesn't know the user cares more about the hero than image #9 - it started ten downloads at once and let them race. In the lazy version, the one thing the user can actually see gets the network to itself, and the other nine only start when there's a real signal ("we're about to scroll there") that they're about to matter.

## It's not about doing less work - it's about doing it later, or never

A common misreading is that lazy loading skips work - mostly it doesn't, it reschedules. The twentieth photo still loads if the user scrolls that far, and the dashboard tab's code still runs the moment it's opened. What lazy loading buys you is that the work happens closer to when it's needed, instead of all being crammed into the first render.

But there's a real bonus hiding in that reschedule: for anyone who *doesn't* scroll that far, or *doesn't* open that tab, the work never happens at all. Nobody had to decide "let's skip the twentieth photo for users who leave early" - it falls out naturally from only doing work once it's asked for.

```text
100 visitors load a 20-photo page, average visitor views the first 4 photos

eager:  100 visitors x 20 photo-downloads = 2,000 downloads
lazy:   100 visitors x  ~4 photo-downloads = ~400 downloads
```

*What just happened:* nobody wrote code that says "only download 4 photos." The savings are a side effect of only fetching what scrolling into view asks for. That's the core appeal - the lazy version isn't a smarter algorithm, it's the same plain rule ("fetch it when it's needed") applied consistently.

## Where this idea shows up outside images

This principle is older and broader than any one web API. A few examples that are all the same idea wearing different clothes:

- A database connection pool that opens connections as requests need them, instead of opening the maximum possible connections at startup.
- A settings screen that only builds its "advanced options" panel when the user clicks "show advanced," instead of building every panel on page load.
- An object whose expensive field is computed the first time something reads it, and cached from then on - rather than computed in the constructor whether anyone reads it or not.

The database example is worth calling out by name: this project has a separate guide on N+1 queries that discusses "lazy loading" in the ORM sense - a related object fetched from the database only when your code touches it. That's the same underlying idea (defer until asked) applied to database relationships instead of UI assets. This guide focuses specifically on the frontend/asset flavor: images, routes, and scroll-triggered content.

## The mental model to keep

One sentence: **do the work when something proves it's needed, not on the chance that it might be.** Whenever you catch yourself loading, fetching, or computing something "just in case," ask whether there's a concrete trigger to wait for instead - a scroll position, a click, a route change. If there is, that's a lazy-loading opportunity; Phase 2 walks through the three places you'll use this constantly.

```quiz
[
  {
    "q": "What does lazy loading actually change about the work being done?",
    "choices": [
      "It makes the work run faster once it starts",
      "It defers the work until something concrete shows it's needed",
      "It moves the work from the browser to the server",
      "It compresses the data before sending it"
    ],
    "answer": 1,
    "explain": "Lazy loading is a scheduling change, not a speed change: the same fetch or computation happens later, triggered by a real signal like scroll position or a click."
  },
  {
    "q": "In the 100-visitor photo example, why did lazy loading cut total downloads without anyone writing a rule like 'only load 4 photos'?",
    "choices": [
      "The browser automatically compresses unseen images",
      "Downloads only start when scrolling proves a photo is about to be seen, so visitors who leave early never trigger the rest",
      "Lazy loading caches images across different visitors",
      "The server refuses to send more than 4 images per session"
    ],
    "answer": 1,
    "explain": "The savings are a side effect of consistently applying 'fetch on demand' - nobody special-cased early-leavers, it falls out of the rule on its own."
  },
  {
    "q": "Why is eager loading not \"wrong\" outright?",
    "choices": [
      "It's always slower in every situation",
      "It's simple to reason about - everything is available immediately, with no scheduling logic",
      "It only works on mobile devices",
      "It's required by every browser"
    ],
    "answer": 1,
    "explain": "Eager loading trades efficiency for simplicity: nothing to schedule, nothing conditional. That tradeoff is sometimes the right one, which Phase 3 covers."
  }
]
```

Watch it animated: [lazy loading](/explainers/LazyLoading.dc.html)


---

# Where you'll actually use it

The principle from Phase 1 is nice to understand, but you'll meet it in three concrete shapes almost every week: images below the fold, code that only some visitors need, and lists that never show everything at once. None of these require a library or a clever algorithm - each one is a small, standard pattern.

## Images below the fold

"Below the fold" means anything not visible without scrolling. A blog post's hero image is above the fold; the diagram in paragraph twelve is below it. Browsers now support lazy loading images natively, with one attribute:

```html
<img src="hero.jpg" alt="Product hero shot">

<!-- further down the page -->
<img src="diagram-12.jpg" alt="Architecture diagram" loading="lazy">
```

*What just happened:* the hero image loads immediately, the way it always did - it's visible right away, so eager is correct for it. The diagram gets `loading="lazy"`, which tells the browser to skip fetching it until the user scrolls close enough that it's about to become visible. No JavaScript, no library - the browser does the scroll-position tracking for you.

A rule of thumb: put `loading="lazy"` on images that start off-screen, and leave the first screenful of images without it. Lazy-loading something the user sees instantly gains you nothing and can even cost you (more on that in Phase 3).

## Route-based code-splitting

A web app with ten pages doesn't need all ten pages' JavaScript before it can show page one. **Code-splitting** breaks the app's code into chunks per route, and a dynamic `import()` fetches a chunk only when that route is visited.

```js
// eager: the settings page's code is bundled into the initial load
import SettingsPage from "./SettingsPage.js";

// lazy: the settings page's code is only fetched when this function runs
function loadSettingsPage() {
  return import("./SettingsPage.js");
}
```

*What just happened:* in the eager version, every visitor downloads `SettingsPage.js` whether they open settings or not. In the lazy version, `import("./SettingsPage.js")` is a function call that returns a promise - the browser only requests that file the first time a route or click triggers it, so a visitor who only looks at the home page never downloads it at all. Most modern frontend frameworks build this pattern in at the router level, so you often just mark a route as lazy and the framework handles the `import()` for you.

## Infinite scroll and "load more"

A feed with ten thousand posts doesn't render ten thousand posts on page load. It renders a first batch - twenty, say - and fetches the next batch only once the user is close to the bottom, or clicks "load more."

```text
1. Render posts 1-20.
2. User scrolls near the bottom of post 20.
3. Fetch posts 21-40, append them.
4. Repeat as the user keeps scrolling.
```

*What just happened:* the server and the client agreed to hand over data in small pieces instead of one enormous response. The trigger differs from images (scroll proximity to an element vs. scroll proximity to the bottom of the list), but the underlying move is identical: wait for a concrete signal, then fetch the next piece. A "load more" button is the same pattern with a click instead of a scroll position as the trigger - some products prefer it because it puts the user in control of when the next fetch happens, rather than firing it automatically.

## The common shape across all three

Each of these patterns follows the same three-part structure:

```text
1. Something visible and immediately needed loads eagerly (the hero image,
   the current route, the first batch of posts).
2. Everything else waits behind a trigger (scroll proximity, a route
   change, reaching the end of the current batch).
3. The trigger fires, the work happens, and from the user's perspective
   it should feel like it was there all along.
```

*What just happened:* that third point is doing a lot of work, and it's the subject of Phase 3. Lazy loading only feels invisible when the timing is right - trigger the fetch a little too late, and the user notices the wait. Get it wrong in a specific way, and you get a worse problem than a wait: content that jumps around after it finally arrives.


---

# The tradeoff

Lazy loading isn't a free performance upgrade you apply everywhere and walk away from. Deferring work means there's a gap between "the page exists" and "this particular piece of it is ready" - and that gap is where things go visibly wrong if you're not careful.

## Layout shift: the space where content isn't yet

Before a lazy-loaded image finishes loading, the browser doesn't know its dimensions - unless you told it. Without that, the browser renders zero height where the image will go, and the instant the image arrives, everything below it gets shoved down.

```text
1. Page renders. Image not loaded yet - its <img> tag takes up 0px of height.
2. User starts reading the paragraph that follows.
3. Image finishes loading, snaps in at 400px tall.
4. Everything below jumps down 400px. The paragraph the user was
   reading is now somewhere else on the screen.
```

*What just happened:* the user experienced **layout shift** - content moving after they've already started interacting with the page. This is jarring on a good day and genuinely harmful on a bad one: a shift at the exact moment someone taps a button can make them tap the wrong thing entirely, because whatever was under their finger moved.

The fix is straightforward: reserve the space up front. Set `width` and `height` attributes (or a CSS aspect-ratio) on lazy-loaded images, so the browser blocks out the right amount of space before the image arrives - even though the pixels themselves aren't there yet.

```html
<img src="diagram-12.jpg" alt="Architecture diagram"
     loading="lazy" width="800" height="450">
```

*What just happened:* the browser now knows this image will be 800 by 450, so it reserves that box immediately, before a single byte of the image has downloaded. When the image does arrive, it fills a space that was already there. Nothing else on the page has to move.

> Lazy loading defers the content. It should never defer the space the content will occupy.

## Flash of missing content

A related problem shows up with lazy-loaded code and data rather than images. If a dashboard tab lazy-loads its code, there's a moment between "user clicked the tab" and "the tab's content is ready to show." Handle that moment badly and the user sees a blank panel, or worse, a flash of an error state, before the real content pops in.

```text
bad:   click tab -> blank white panel -> content suddenly appears
better: click tab -> a placeholder that matches the content's shape
                      (a skeleton, a spinner sized to fit) -> content
                      replaces the placeholder in the same spot
```

*What just happened:* the "better" version doesn't make the fetch any faster - it's the same lazy load, the same wait. What changes is that the user isn't staring at an unexplained blank space wondering if something broke. A placeholder that already occupies the right amount of room solves both problems at once: no layout shift when the real content lands, and no confusing gap while it's in flight.

## When eager beats lazy

Lazy loading isn't always the right call. Skip it entirely when:

- **Small content.** If an image is a 2 KB icon, the overhead of setting up a scroll observer to decide *when* to load it can cost more than downloading the icon outright would have. Lazy loading pays off when the deferred thing is expensive enough that skipping it (for users who never trigger it) actually matters.
- **Critical above-the-fold content.** Anything the user needs the instant the page appears - the main headline image, the primary call-to-action button's icon, the first few rows of a table they came to read - should load eagerly. Deferring something the user is guaranteed to need immediately only adds a delay with no corresponding benefit.
- **Content you can't cheaply reserve space for.** If you genuinely cannot predict a lazy-loaded element's size ahead of time (dynamic-height content, for instance), you're trading a slow load for a layout-shift risk. Eager-loading that content - even at the cost of a slightly heavier initial load - is often the safer choice over a shift that annoys or misdirects every visitor.

The underlying question is the same one from Phase 1, asked in reverse: is there a real chance this won't be needed? If the answer is "no, everyone who loads this page needs this immediately," lazy loading has nothing to offer - you're adding a deferral mechanism to something that was never optional.
