PushBackLog

Lazy Loading

Advisory enforcement Complete by PushBackLog team
Topic: performance Topic: quality Skillset: frontend Skillset: backend Technology: generic Stage: execution Stage: review

Lazy Loading

Status: Complete
Category: Performance
Default enforcement: Advisory
Author: PushBackLog team


Tags

  • Topic: performance, quality
  • Skillset: frontend, backend
  • Technology: generic
  • Stage: execution, review

Summary

Lazy loading defers the loading or initialisation of a resource until it is actually needed, rather than loading everything upfront. Applied to code bundles, images, data fetches, and module imports, it reduces initial load time and resource consumption.


Rationale

Why initial load time matters

Google’s Core Web Vitals research establishes clear relationships between load time and user outcomes: pages that load in under 1 second convert at 3× the rate of pages that take 5 seconds. Every kilobyte sent to the browser on first load that the user doesn’t need immediately is a tax on that conversion rate. Lazy loading is the mechanism for paying that tax only when the invoice is actually due.

The cost of eager loading everything

An application that loads all JavaScript, all images, and all data on the initial page render is paying three costs it didn’t need to pay:

  1. Download time: fewer bytes sent means faster transfer on any connection
  2. Parse and compile time: JavaScript must be parsed and JIT-compiled before it executes; deferring unused code defers that cost
  3. Memory: images and data loaded that the user never views consume memory that could be used for interactions they actually perform

Where lazy loading applies

Lazy loading is applicable at several layers:

  • Code splitting: JavaScript bundles split by route or component, loaded on demand
  • Images: below-the-fold images loaded as they scroll into view
  • Data fetching: related data fetched on demand (when a user expands a section)
  • Module imports: heavy library modules (charting, PDF, rich text editors) imported only when needed
  • Database relationships: ORM associations loaded on access, not upfront (configurable in most ORMs)

Guidance

Frontend: code splitting

// Without code splitting: everything bundled together
import { ReportsPage } from './pages/ReportsPage';
import { AdminDashboard } from './pages/AdminDashboard';
// Both pages load on every visit, even if user never visits admin

// With lazy loading (React)
const ReportsPage = React.lazy(() => import('./pages/ReportsPage'));
const AdminDashboard = React.lazy(() => import('./pages/AdminDashboard'));
// Each page's bundle is loaded only when the user navigates to it

Frontend: images

<!-- Modern browsers: native lazy loading -->
<img src="/images/hero.jpg" loading="eager" alt="Hero" />  <!-- Above fold: eager -->
<img src="/images/blog-post.jpg" loading="lazy" alt="Post" /> <!-- Below fold: lazy -->

Rule: loading="eager" for Largest Contentful Paint (LCP) candidates (largest above-fold image). loading="lazy" for everything else.

Backend: on-demand data

For expensive sub-resources, prefer lazy fetching over including everything in the initial response:

// Heavy-handed: fetch everything upfront
const dashboard = await getDashboard(userId); // includes stats, chart data, recent activity

// Lazy: initial page loads fast, charts load when user scrolls to them
const dashboard = await getDashboardSummary(userId); // fast
// Charts loaded separately when user scrolls to the chart section
--- expanded sections, tabs, etc. loaded on interaction

What to keep eager

  • Content visible in the initial viewport (above the fold)
  • The Largest Contentful Paint candidate
  • Critical path JavaScript (routing, auth)
  • Data required to render the meaningful first paint

Examples

Route-based code splitting

// React Router with React.lazy
import { Suspense, lazy } from 'react';

const Checkout = lazy(() => import('./pages/Checkout'));
const Account = lazy(() => import('./pages/Account'));

function App() {
  return (
    <Router>
      <Suspense fallback={<Spinner />}>
        <Routes>
          {/* Checkout bundle only downloads when user navigates to /checkout */}
          <Route path="/checkout" element={<Checkout />} />
          <Route path="/account" element={<Account />} />
        </Routes>
      </Suspense>
    </Router>
  );
}

Intersection Observer for custom lazy loading

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      loadChartData(entry.target.dataset.chartId);
      observer.unobserve(entry.target); // Load once
    }
  });
});
document.querySelectorAll('[data-chart-id]').forEach(el => observer.observe(el));

Anti-patterns

1. Lazy loading above-the-fold content

Lazy loading the hero image or primary heading creates a visible pop-in on load — exactly the content users see first. This harms LCP and perceived performance. Lazy load below-the-fold only.

2. Over-splitting into many tiny chunks

Splitting every component into its own lazy-loaded module creates request waterfalls: the user downloads a route bundle that then triggers 12 more micro-bundle fetches. The overhead of many small fetches can exceed the savings. Split at route or major section level.

3. No loading state

Lazy loading creates a gap between user action and content appearance. Without a spinner or skeleton screen, the UI appears broken. Every lazy-loaded section needs a Suspense boundary or equivalent loading indicator.

4. Lazy loading in a loop creating N+1 requests

for (const item of items) {
  item.author = await loadAuthor(item.authorId); // N separate requests
}

This is the N+1 problem in async form. Batch the fetch instead.



Part of the PushBackLog Best Practices Library. Suggest improvements →