DEV Community

Mateus Cechetto
Mateus Cechetto

Posted on

Lazy Loading Pattern: When and Why to Use It

What is Lazy Loading?

Lazy Loading is a design pattern that defers the initialization of an object or resource until it is actually needed. Instead of creating the object at the start of the program or application, the system waits until the first time the object is accessed, at which point it is created and cached for future use. As Martin Fowler wrote:

An object that doesn't contain all of the data you need but knows how to get it.

This is particularly useful when dealing with expensive objects or resources that might not always be required. By delaying their creation until they are needed, Lazy Loading can help improve performance, save memory, and reduce unnecessary overhead.

Example: Lazy Loading in Action

Let’s take a look at an example to see how Lazy Loading works in practice.

In our case, we are building a system based on a card-based game, and one of the cards, the Mothership, has related cards that can be accessed. These related cards are minions with the Protoss tag. Here's how it could be implemented:

Computing on Every Call

public class Mothership: ICardWithRelatedCards
{
    public List<Card?> GetRelatedCards(Player player)
    {
        return HearthDb.Cards.Collectible.Values
            .Where(c => c.Entity.GetTag(GameTag.PROTOSS) > 0 && c.Type == CardType.MINION)
            .Select(c => new Card(c))
            .ToList();
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation: the related cards are computed every time GetRelatedCards() is called, regardless of whether they were computed before.

Pros:

  • No Initial Cost: It is only calculated when the method is called.

Cons:

  • High Overhead: This approach incurs a high performance cost since the list is recomputed every time it is requested.
  • Inefficient: If the related cards don’t change often or are used repeatedly, recalculating them on every call is wasteful in terms of both CPU and memory.

Immediate Initialization

public class Mothership: ICardWithRelatedCards
{
    private List<Card?> _options = HearthDb.Cards.Collectible.Values
        .Where(c => c.Entity.GetTag(GameTag.PROTOSS) > 0 && c.Type == CardType.MINION)
        .Select(c => new Card(c))
        .ToList();

    public List<Card?> GetRelatedCards(Player player) => _options;
}
Enter fullscreen mode Exit fullscreen mode

Explanation: the related cards are computed immediately when the object is created, regardless of whether they will be used.

Pros:

  • Simpler Code: The code is simpler because there’s no need to check for null or implement caching.
  • No Delay on Access: The list is always available, so there is no initial delay when accessing the related cards.

Cons:

  • Wasted Resources: If the related cards are not always needed (as in our case, when the card isn’t played in a game), this approach can waste memory. All related cards are precomputed and stored in memory, even if they are never used.
  • High Initial Cost: When an object is instantiated, the related cards are computed right away, which can slow down initialization when creating many card objects.

Initially, we used Immediate Initialization for the related cards, but the approach introduced problems when creating many card objects because of the high inital cost and memory usage.

Lazy Loading Pattern

public class Mothership: ICardWithRelatedCards
{
    private List<Card?>? _cache = null;

    public List<Card?> GetRelatedCards(Player player)
    {
        if (_cache != null) return _cache;

        _cache = HearthDb.Cards.Collectible.Values
            .Where(c => c.Entity.GetTag(GameTag.PROTOSS) > 0 && c.Type == CardType.MINION)
            .Select(c => new Card(c))
            .ToList();

        return _cache;
    }
}
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Lazy Loading: The list of related cards (_cache) is initially set to null. Only when GetRelatedCards() is called for the first time is the list populated with the minions that have the PROTOSS tag.
  • Caching: After the list is created, it’s cached in the _cache field, so subsequent calls to GetRelatedCards() return the cached list without recalculating it.

Why Lazy Loading Works Best

  • Memory Efficiency: The related cards are only computed when necessary, and the list is cached for future calls. If the related cards aren’t used, they are never computed, saving memory.
  • Reduced Initialization Cost: The initial creation of the object is faster because the related cards are not precomputed upfront.
  • No Repeated Computation: Once the list of related cards is computed, it is cached, preventing repeated calculations and reducing CPU overhead.

Trade-offs:

  • The primary trade-off of Lazy Loading is the initial delay on the first access of the related cards, but this is usually a small price to pay for significant memory savings and improved performance over the long term.

Cache Invalidation

One of the key considerations when using Lazy Loading (or caching in general) is cache invalidation. While caching can significantly improve performance by reducing the need for repeated calculations or data retrieval, it also introduces the challenge of ensuring that cached data remains accurate and up-to-date.

When data changes—whether because of user actions, external data updates, or any other factors—the cached version of the data can become stale. This means that when you access the cache, you might be retrieving outdated or incorrect information. To prevent this, caches need to be invalidated or refreshed at appropriate times.

In our case, we are working with a static pool of cards. The related cards are based on predefined tags and type, and this data does not change dynamically during gameplay. The card pool is known and limited: the existent cards are fixed, and there are no external factors or events that would require frequent updates to the related cards. Because of that, cache invalidation is not a concern in our specific scenario. As a result, once the related cards are cached, they can be reused without worrying about data becoming stale.

However, if our scenario involved dynamic data (for example, if the tags or types of cards could change during gameplay, or if new cards were introduced or removed) then cache invalidation strategies would need to be implemented. To handle this, we would need to implement cache invalidation strategies, such as:

  • Time-based expiration: Cache is invalidated after a certain amount of time has passed.
  • Manual invalidation: Cache is manually cleared when the system detects that a change has occurred.
  • Event-based invalidation: Cache is invalidated when specific events occur.

For our hypothetical problem, event-based invalidation would be the better choice because it automatically handles changes and keeps your cache in sync without manual intervention. For example, in a game with real-time card updates, events like "card changed," "card removed," or "new card added" could trigger the invalidation of caches for related cards.

Conclusion

Lazy Loading is a powerful design pattern that can help optimize performance by deferring the creation of expensive objects until they are needed.
By comparing Lazy Loading with Immediate Initialization and Computing on Every Call, we can clearly see its benefits in scenarios where resources like memory and CPU time need to be managed carefully. When used correctly, Lazy Loading can greatly enhance the performance of your application, especially in resource-constrained environments.

Top comments (0)