Scalability & Performance Scalability

Lazy Loading

Deferring initialization until needed; lazy singleton, lazy DB relations (N+1 problem and its fix), lazy loading in UI (Intersection Observer), virtual scrolling, and skeleton screens.

⏱ 11 min read

What it is

Lazy loading is the design pattern of deferring the initialization or loading of a resource until the moment it is actually needed, rather than loading everything up front. The fundamental insight is that systems often load significantly more data than users ever access in a given session; loading it eagerly wastes resources and increases startup latency. By deferring load to the point of actual access, the system pays the initialization cost only when necessary, and sometimes avoids it entirely (if the resource is never accessed).

The pattern appears at multiple architectural levels: lazy initialization in application code (initialize an object the first time it's accessed, not at application startup), lazy loading of database relations in ORMs (load associated records only when the association is accessed, not on the initial query), lazy loading of assets in UIs (load images and components only when they scroll into the viewport), and virtual scrolling (render only the visible rows of a large list, fetching data for off-screen rows on demand).

Why it exists

Startup time and initial page load time are critical metrics. An application that eagerly initializes 50 services, loads 10,000 product images, and fetches megabytes of JSON at startup will be unresponsive for seconds before the user can interact with it. Lazy loading decouples initialization from startup, allowing the critical path to complete quickly while non-essential resources load in the background or on demand.

Memory usage is the other motivating factor. A product listing page might display 12 products but the catalog contains 100,000. Eagerly loading all 100,000 products into memory would require gigabytes of RAM and take minutes. Lazy loading (fetching the current page of results only) reduces this to kilobytes. The same applies to database ORM queries: a user object with lazily-loaded associations returns quickly with just the user record; loading all orders, all addresses, all payment methods eagerly would result in hundreds of database joins that are almost always unnecessary for the immediate use case.

When to use

  • Resources (images, scripts, data, services) are large, expensive to load, and not always needed by every user or code path.
  • Application startup time is a concern — lazy initialization allows deferring non-critical services to after the application becomes responsive.
  • UI renders lists, grids, or feeds with potentially thousands of items — only the visible portion needs to be loaded.
  • ORM-based data access where associated records are needed only in some code paths, not all.
  • Module loading in large SPAs — route-based code splitting loads JS bundles only for the routes the user visits.

When not to use

  • Resources are always needed immediately — lazy loading adds a lookup indirection cost with no benefit.
  • Sequential ORM lazy loading of relations in a loop — this is the N+1 problem and is actively harmful for performance.
  • Security-critical initializations (auth middleware, input validation) that must be active at startup, not deferred.
  • Resources needed for the critical rendering path — lazy loading above-the-fold images degrades perceived performance.

Typical architecture


  Lazy Singleton (thread-safe, double-checked locking):
  class ConfigService {
    private static volatile instance: ConfigService | null = null;
    static getInstance(): ConfigService {
      if (!this.instance) {
        synchronized(ConfigService) {
          if (!this.instance) {            // double-check after lock
            this.instance = new ConfigService(); // load config from disk
          }
        }
      }
      return this.instance;
    }
  }

  ORM Lazy Loading vs. N+1 Problem:
  // BAD: lazy loading in a loop → N+1 queries
  const orders = await Order.findAll(); // 1 query
  for (const order of orders) {
    const items = await order.getItems(); // N queries (one per order!)
  }
  // 1 + N queries total for N orders

  // GOOD: eager loading with JOIN (fix for N+1)
  const orders = await Order.findAll({
    include: [{ model: Item }]  // JOIN in 1 query
  });
  // 1 query total

  UI: Intersection Observer API (native lazy images):
  <img src="placeholder.gif" data-src="product-photo.jpg" loading="lazy">
  // Browser natively defers loading until image is near viewport

  // Custom Intersection Observer:
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.src = entry.target.dataset.src;
        observer.unobserve(entry.target);
      }
    });
  }, { rootMargin: '200px' }); // pre-load 200px before visible

  Virtual Scrolling (only render visible rows):
  Total list: 100,000 rows in memory (IDs + minimal data)
  Rendered DOM: only ~20 rows currently in viewport
  On scroll: recycle DOM nodes, update data bindings
  Libraries: react-virtualized, TanStack Virtual, @angular/cdk

  Skeleton Screen Pattern:
  1. Render skeleton placeholder immediately (0ms)
  2. Fetch data asynchronously
  3. Replace skeleton with real content (300-1000ms)
  → User sees activity immediately, no blank white screen
          

Pros and cons

Pros

  • Reduces initial load time and startup latency by deferring non-critical resource initialization.
  • Reduces memory usage: resources that are never accessed are never loaded.
  • Improves perceived performance: users see a responsive UI immediately while secondary content loads progressively.
  • Reduces database and API load: resources are only fetched when needed, not speculatively for all possible code paths.
  • Virtual scrolling makes infinite lists with thousands of items performant in browsers, which cannot render 100,000 DOM nodes efficiently.

Cons

  • Introduces latency on first access — the user waits at the moment of need rather than during a background pre-load.
  • ORM lazy loading in loops causes the N+1 query problem — a catastrophic performance anti-pattern when misapplied.
  • Lazy initialization adds complexity: thread-safety must be carefully implemented in multi-threaded environments.
  • Below-the-fold images loading on scroll can cause layout shifts (CLS) if image dimensions are not reserved with placeholder space.
  • Virtual scrolling implementations can be complex; keyboard navigation, accessibility, and screen reader support require careful implementation.

Implementation notes

The N+1 problem: ORM lazy loading is the source of the most common database performance problem in application development. The N+1 pattern: load a list of N parent records, then for each parent access a lazily-loaded association, triggering N additional queries. For 100 orders with lazy-loaded items, this is 101 queries instead of 1. The fix is always eager loading (JOIN-based loading of the association in the original query). In Hibernate/JPA, use JOIN FETCH or @EntityGraph. In Sequelize, use include options. In Rails ActiveRecord, use includes. Enable SQL query logging in development and review it for N+1 patterns; they are almost never obvious from reading the application code alone.

Intersection Observer for images: The HTML loading="lazy" attribute provides native browser lazy loading for images and iframes with no JavaScript required. It defers loading of off-screen images until they are near the viewport. For custom components (lazy sections, code-split React components), use the Intersection Observer API with a rootMargin of 200–400px to pre-load just before the user scrolls to the element, avoiding visible loading delays. Always specify width and height attributes on lazily-loaded images to reserve space and prevent Cumulative Layout Shift (CLS), which is a Core Web Vital metric.

Common failure modes

  • N+1 query problem: ORM lazy loading inside a loop generates N+1 database queries. This is the most common, most impactful, and most invisible lazy loading failure mode. Always use query logging and profiling tools to detect N+1 in development.
  • Layout shift on lazy image load: Images without reserved dimensions cause the page to reflow as they load, producing poor CLS scores and jarring user experience. Always set explicit width/height or use CSS aspect-ratio containers.
  • Race conditions in lazy singleton initialization: Two threads simultaneously find the instance is null and both initialize it. In Java, use volatile + double-checked locking or the initialization-on-demand holder idiom. In modern languages, use built-in lazy initialization primitives (lazy in Kotlin, std::call_once in C++).
  • Deferred security initialization: Security middleware lazily initialized on first request may miss the very first request before initialization completes. Security components must always be eagerly initialized at startup.

Decision checklist

  • ORM associations are loaded eagerly (JOIN) for code paths that always need them; lazy loading is only used where associations are genuinely optional.
  • Query logging is enabled in development to detect N+1 patterns before they reach production.
  • Lazily-loaded images use loading="lazy" or Intersection Observer with explicit width/height to prevent layout shift.
  • Virtual scrolling is used for lists exceeding ~1,000 items to prevent DOM node accumulation.
  • Lazy singleton implementations are thread-safe with appropriate synchronization primitives.
  • Above-the-fold, critical-path resources are NOT lazily loaded — they must be available immediately.

Example use cases

  • E-commerce product catalog: A product listing page renders the first 24 products server-side. Below-the-fold product images use loading="lazy". Scrolling down triggers infinite scroll: new product pages are fetched and appended to the DOM only as the user approaches the bottom, keeping the initial page small and fast.
  • SPA route-based code splitting: A React application uses React.lazy() with dynamic import() to split the bundle by route. The checkout page bundle (200KB) is only downloaded when the user navigates to /checkout, not on initial app load. The initial bundle drops from 800KB to 200KB, improving Time-to-Interactive by 2 seconds on mobile networks.
  • Data grid with 500,000 rows: A reporting dashboard displays a 500,000-row result set using TanStack Virtual. Only 50 rows are rendered in the DOM at any time; scrolling through 500,000 rows remains smooth at 60fps because DOM node count stays constant and new data is fetched in batches via a virtualized data source.
  • Materialized Views — the inverse tradeoff: pre-computing expensive queries eagerly to avoid per-request computation latency.
  • Caching Strategies — cache-aside is effectively lazy loading plus result caching: load on first miss, serve from cache thereafter.
  • Partitioning Patterns — pagination is a form of lazy loading at the data layer, loading one partition of results at a time.

Further reading