Loading learning content...
Every application loads data that users never access. Database queries fetch related records that go unread. API responses include nested objects that are never rendered. This eager loading of potentially unused data wastes bandwidth, memory, and processing time.
Lazy loading defers computation or data retrieval until the moment it's actually needed. When combined with caching, lazy loading becomes even more powerful: you not only defer the work but also ensure that once performed, the work is never repeated unnecessarily.
This combination—lazy initialization with cached results—is a foundational pattern in high-performance object-oriented design. It appears in ORMs (lazy-loaded relationships), UI frameworks (virtual scrolling), dependency injection containers, and countless domain-specific implementations.
By the end of this page, you will understand the lazy loading pattern and its variations, implement lazy-cached properties in object-oriented designs, recognize when lazy loading improves versus harms performance, and handle the complexities of lazy loading in concurrent environments.
Lazy loading is a design pattern that delays the initialization of an object or computation until it's first accessed. The opposite approach—eager loading—initializes everything upfront, regardless of whether it will be used.
The Core Principle:
Lazy loading follows a simple contract: "Don't pay for what you don't use." This principle becomes critical when:
Eager vs. Lazy Loading Comparison:
| Aspect | Eager Loading | Lazy Loading |
|---|---|---|
| Initialization | All at once, upfront | On-demand, when accessed |
| Startup time | Slower (loads everything) | Faster (loads nothing) |
| First access time | Immediate (already loaded) | Slower (triggers load) |
| Memory usage | Higher (all data resident) | Lower (only used data) |
| Predictability | Consistent performance | Variable (cold vs. warm) |
| N+1 query risk | None (batch loading) | High (individual loads) |
The most common form of lazy loading with caching is the lazy-cached property: a property that computes its value on first access and then caches it for subsequent accesses.
Basic Lazy Property Pattern:
123456789101112131415161718192021222324252627282930
class User { private _profileCache: UserProfile | null = null; private _profileLoaded: boolean = false; constructor( public readonly id: string, public readonly email: string, private readonly profileRepository: ProfileRepository ) {} // Lazy-cached property with explicit null handling get profile(): UserProfile | null { if (!this._profileLoaded) { this._profileCache = this.profileRepository.findByUserId(this.id); this._profileLoaded = true; } return this._profileCache; }} // Usageconst user = new User('123', 'user@example.com', profileRepo);// Profile not loaded yet - no database query console.log(user.email); // Immediate - no lazy loading// Profile still not loaded console.log(user.profile?.displayName); // NOW profile loads// Subsequent accesses use cacheconsole.log(user.profile?.avatar); // No new queryGeneric Lazy<T> Container:
For cleaner, reusable lazy loading, encapsulate the pattern in a dedicated container:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
class Lazy<T> { private _value: T | undefined; private _initialized: boolean = false; constructor(private readonly factory: () => T) {} get value(): T { if (!this._initialized) { this._value = this.factory(); this._initialized = true; } return this._value!; } get isInitialized(): boolean { return this._initialized; } // Allow resetting for cache invalidation reset(): void { this._value = undefined; this._initialized = false; }} // Async variantclass AsyncLazy<T> { private _promise: Promise<T> | null = null; private _value: T | undefined; private _resolved: boolean = false; constructor(private readonly factory: () => Promise<T>) {} async getValue(): Promise<T> { if (this._resolved) { return this._value!; } // Cache the promise to prevent duplicate requests if (!this._promise) { this._promise = this.factory().then(value => { this._value = value; this._resolved = true; return value; }); } return this._promise; }} // Usage in domain objectsclass Order { private _customer: Lazy<Customer>; private _items: AsyncLazy<OrderItem[]>; constructor( public readonly id: string, private readonly customerRepo: CustomerRepository, private readonly itemsRepo: OrderItemRepository ) { this._customer = new Lazy(() => this.customerRepo.findById(this.customerId) ); this._items = new AsyncLazy(() => this.itemsRepo.findByOrderId(this.id) ); } get customer(): Customer { return this._customer.value; } async getItems(): Promise<OrderItem[]> { return this._items.getValue(); }}Object-Relational Mappers (ORMs) extensively use lazy loading for relationship navigation. When you access a related entity, the ORM transparently executes the necessary query.
Virtual Proxy Pattern:
ORMs often implement lazy loading through a Virtual Proxy—an object that stands in for the real entity until access triggers loading:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Interface for the domain objectinterface Customer { id: string; name: string; email: string; orders: Order[];} // Virtual proxy that loads on accessclass CustomerProxy implements Customer { private _realCustomer: Customer | null = null; constructor( public readonly id: string, private readonly loader: (id: string) => Customer ) {} private ensureLoaded(): Customer { if (!this._realCustomer) { console.log(`Loading customer ${this.id}...`); this._realCustomer = this.loader(this.id); } return this._realCustomer; } get name(): string { return this.ensureLoaded().name; } get email(): string { return this.ensureLoaded().email; } get orders(): Order[] { return this.ensureLoaded().orders; }} // ORM returns proxy, not real objectclass OrderRepository { findById(id: string): Order { const row = this.db.query('SELECT * FROM orders WHERE id = ?', id); return { id: row.id, total: row.total, // Return proxy instead of loading customer immediately customer: new CustomerProxy( row.customer_id, (id) => this.customerRepo.findById(id) ) }; }}Lazy loading's biggest pitfall is the N+1 problem: loading a list of N items, then triggering N additional queries when accessing a lazy property on each. For 100 orders, this means 101 queries instead of 2. Always consider eager loading when iterating over collections.
12345678910111213141516171819202122
// ❌ N+1 Problem - 101 queries for 100 ordersconst orders = await orderRepository.findAll(); // 1 queryfor (const order of orders) { console.log(order.customer.name); // 100 queries!} // ✅ Eager loading - 2 queries totalconst orders = await orderRepository.findAllWithCustomers(); // 2 queriesfor (const order of orders) { console.log(order.customer.name); // Already loaded} // ✅ Batch loading - 2 queries with explicit controlconst orders = await orderRepository.findAll();const customerIds = orders.map(o => o.customerId);const customers = await customerRepository.findByIds(customerIds);const customerMap = new Map(customers.map(c => [c.id, c])); for (const order of orders) { const customer = customerMap.get(order.customerId); console.log(customer?.name);}In concurrent environments, naive lazy loading creates race conditions. Multiple threads may simultaneously detect the uninitialized state and trigger redundant computations—or worse, observe partially initialized state.
Double-Checked Locking Pattern:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Thread-safe lazy initialization (conceptual - TypeScript is single-threaded)// This pattern is critical in Java, C#, Go, etc. class ThreadSafeLazy<T> { private value: T | null = null; private initialized: boolean = false; private readonly lock = new Mutex(); constructor(private readonly factory: () => T) {} getValue(): T { // First check without lock (fast path) if (this.initialized) { return this.value!; } // Acquire lock for initialization return this.lock.runExclusive(() => { // Double-check after acquiring lock if (!this.initialized) { this.value = this.factory(); this.initialized = true; } return this.value!; }); }} // Modern approach: Promise-based for async contextsclass AsyncCachingLoader<T> { private promise: Promise<T> | null = null; constructor(private readonly loader: () => Promise<T>) {} async load(): Promise<T> { // Cache the promise itself, not just the result // This prevents duplicate in-flight requests if (!this.promise) { this.promise = this.loader(); } return this.promise; } invalidate(): void { this.promise = null; }}In async contexts, always cache the Promise object rather than waiting for its resolution before caching. This ensures that concurrent callers receive the same Promise, preventing duplicate requests even when the first request is still in flight.
loadWithRelations() for batch scenariosYou now understand lazy loading with caching—how to defer expensive work until needed and ensure it's never repeated unnecessarily. Next, we'll explore caching computed properties, where derived values are calculated once and cached to avoid redundant recalculation.