LazyLoad
Automatic caching with request deduplication for efficient data loading
Overview
LazyLoad provides automatic caching with configurable expiration and intelligent request deduplication.
When multiple components request the same data simultaneously, only one API call is made and the result is shared
across all requesters.
What Problem Does It Solve?
Without LazyLoad (15+ lines)
// Manual caching with deduplication
private readonly Dictionary<string, (object Data, DateTime Expiry)> _cache = new();
private readonly Dictionary<string, Task> _pending = new();
private readonly SemaphoreSlim _lock = new(1, 1);
async Task<User> LoadUser(int userId)
{
var key = $"user-{userId}";
await _lock.WaitAsync();
try
{
// Check cache
if (_cache.TryGetValue(key, out var cached) && cached.Expiry > DateTime.UtcNow)
return (User)cached.Data;
// Check if request in flight
if (_pending.TryGetValue(key, out var task))
{
_lock.Release();
return await (Task<User>)task;
}
// Start new request
var loadTask = UserService.GetUserAsync(userId);
_pending[key] = loadTask;
_lock.Release();
var user = await loadTask;
await _lock.WaitAsync();
_cache[key] = (user, DateTime.UtcNow.AddMinutes(5));
_pending.Remove(key);
return user;
}
finally
{
if (_lock.CurrentCount == 0) _lock.Release();
}
}
With LazyLoad (2 lines)
var user = await LazyLoad(
$"user-{userId}",
() => UserService.GetUserAsync(userId),
cacheFor: TimeSpan.FromMinutes(5));
- Automatic caching with configurable expiration
- Automatic request deduplication (10 simultaneous calls = 1 API request)
- Thread-safe without manual locking
- No manual cache management
- Clean, declarative API
How Request Deduplication Works
When multiple components request the same data before the first request completes, LazyLoad ensures only one API call is made:
┌─────────────────────────────────────────────────────────┐
│ Timeline: 10 Components Request user-123 Simultaneously │
└─────────────────────────────────────────────────────────┘
Component 1: LazyLoad("user-123", ...) → [Starts API call]
Component 2: LazyLoad("user-123", ...) → [Waits for #1]
Component 3: LazyLoad("user-123", ...) → [Waits for #1]
...
Component 10: LazyLoad("user-123", ...) → [Waits for #1]
↓
[Single API call completes]
↓
[All 10 components receive data]
↓
[Result cached for 5 min]
↓
[Next request within 5 min = instant]
Complete Example
Product Details with Caching
@page "/product/{ProductId:int}"
@inherits StoreComponent<ProductState>
@inject IProductService ProductService
<div class="product-details">
@if (State.Details.IsLoading)
{
<p>Loading product...</p>
}
else if (State.Details.HasData)
{
var product = State.Details.Data;
<h1>@product.Name</h1>
<p>@product.Description</p>
<p class="price">$@product.Price</p>
<button @onclick="LoadReviews">Load Reviews</button>
@if (State.Reviews.HasData)
{
<div class="reviews">
@foreach (var review in State.Reviews.Data)
{
<div class="review">
<strong>@review.Author</strong>
<p>@review.Comment</p>
</div>
}
</div>
}
}
</div>
@code {
[Parameter] public int ProductId { get; set; }
protected override async Task OnInitializedAsync()
{
await LoadProduct();
}
async Task LoadProduct()
{
await Update(s => s with { Details = s.Details.ToLoading() });
// Cached for 5 minutes - multiple tabs showing same product = 1 API call
var product = await LazyLoad(
$"product-{ProductId}",
() => ProductService.GetProductAsync(ProductId),
cacheFor: TimeSpan.FromMinutes(5));
await Update(s => s with { Details = AsyncData.Success(product) });
}
async Task LoadReviews()
{
await Update(s => s with { Reviews = s.Reviews.ToLoading() });
// Cached for 2 minutes - reviews change more frequently
var reviews = await LazyLoad(
$"reviews-{ProductId}",
() => ProductService.GetReviewsAsync(ProductId),
cacheFor: TimeSpan.FromMinutes(2));
await Update(s => s with { Reviews = AsyncData.Success(reviews) });
}
}
// State.cs
public record ProductState(
AsyncData<Product> Details,
AsyncData<ImmutableList<Review>> Reviews)
{
public static ProductState Initial => new(
AsyncData<Product>.NotAsked(),
AsyncData<ImmutableList<Review>>.NotAsked());
}
User Profile with Related Data
@page "/profile/{UserId}"
@inherits StoreComponent<ProfileState>
@inject IUserService UserService
@code {
[Parameter] public string UserId { get; set; } = "";
protected override async Task OnParametersSetAsync()
{
// Load user, posts, and followers in parallel
// If multiple components load same user, only 1 API call per resource
var loadTasks = new[]
{
LoadUser(),
LoadPosts(),
LoadFollowers()
};
await Task.WhenAll(loadTasks);
}
async Task LoadUser()
{
var user = await LazyLoad(
$"user-{UserId}",
() => UserService.GetUserAsync(UserId),
cacheFor: TimeSpan.FromMinutes(10));
await Update(s => s with { User = AsyncData.Success(user) });
}
async Task LoadPosts()
{
var posts = await LazyLoad(
$"posts-{UserId}",
() => UserService.GetPostsAsync(UserId),
cacheFor: TimeSpan.FromMinutes(5));
await Update(s => s with { Posts = AsyncData.Success(posts) });
}
async Task LoadFollowers()
{
var followers = await LazyLoad(
$"followers-{UserId}",
() => UserService.GetFollowersAsync(UserId),
cacheFor: TimeSpan.FromMinutes(5));
await Update(s => s with { Followers = AsyncData.Success(followers) });
}
}
API Reference
Method Signature
protected Task<T> LazyLoad<T>(
string key,
Func<Task<T>> loader,
TimeSpan? cacheFor = null)
Parameters
| Parameter | Type | Description |
|---|---|---|
key |
string |
Unique cache key. Use descriptive keys like "user-{id}", "products-page-{page}" |
loader |
Func<Task<T>> |
Async function that loads the data (typically an API call) |
cacheFor |
TimeSpan? |
Cache duration. Default is 5 minutes if not specified |
Return Value
Returns Task<T> - The loaded data (from cache if valid, or from loader if expired/missing)
Behavior
- Cache Hit: Returns cached data immediately (no API call)
- Cache Miss: Calls loader, caches result, returns data
- Request In-Flight: Waits for existing request and shares result
- Cache Expiration: After
cacheForduration, next request will reload
Common Patterns
Pattern 1: Different Cache Durations
// Rarely changing data - long cache
var config = await LazyLoad(
"app-config",
() => ConfigService.GetConfigAsync(),
cacheFor: TimeSpan.FromHours(1));
// Frequently changing data - short cache
var livePrice = await LazyLoad(
$"price-{symbol}",
() => StockService.GetPriceAsync(symbol),
cacheFor: TimeSpan.FromSeconds(30));
// User session data - medium cache
var userProfile = await LazyLoad(
$"profile-{userId}",
() => UserService.GetProfileAsync(userId),
cacheFor: TimeSpan.FromMinutes(5));
Pattern 2: Paginated Data
async Task LoadPage(int pageNumber)
{
// Each page cached separately
var products = await LazyLoad(
$"products-page-{pageNumber}",
() => ProductService.GetPageAsync(pageNumber, pageSize: 20),
cacheFor: TimeSpan.FromMinutes(10));
await Update(s => s.SetPage(pageNumber, products));
}
Pattern 3: Cache Invalidation
@inject ILazyCache Cache
async Task UpdateProduct(Product product)
{
// Update via API
await ProductService.UpdateAsync(product);
// Invalidate cache so next load fetches fresh data
Cache.Invalidate($"product-{product.Id}");
// Reload with fresh data
await LoadProduct(product.Id);
}
Pattern 4: Related Data Loading
async Task LoadOrderWithDetails(int orderId)
{
// Load order and related data in parallel
// Deduplication ensures shared data (like customer) only loads once
var loadTasks = new[]
{
LazyLoad($"order-{orderId}", () => OrderService.GetOrderAsync(orderId)),
LazyLoad($"customer-{customerId}", () => CustomerService.GetAsync(customerId)),
LazyLoad($"order-items-{orderId}", () => OrderService.GetItemsAsync(orderId))
};
await Task.WhenAll(loadTasks);
}
Pattern 5: Conditional Loading
async Task LoadUserData(string userId)
{
// Always load basic profile (cached)
var user = await LazyLoad(
$"user-{userId}",
() => UserService.GetAsync(userId),
cacheFor: TimeSpan.FromMinutes(10));
// Only load expensive data if user is premium
if (user.IsPremium)
{
var analytics = await LazyLoad(
$"analytics-{userId}",
() => AnalyticsService.GetAsync(userId),
cacheFor: TimeSpan.FromMinutes(5));
await Update(s => s.SetAnalytics(analytics));
}
}
Best Practices
✅ Do
- Use descriptive cache keys - Include entity type and ID:
"user-{id}","products-category-{cat}" - Match cache duration to data volatility - Frequent changes = shorter cache, stable data = longer cache
- Combine with AsyncData<T> - Use together for loading states + caching
- Invalidate on mutations - Clear cache after create/update/delete operations
- Use for expensive operations - API calls, database queries, complex computations
- Leverage deduplication - Don't worry about simultaneous requests, LazyLoad handles it
❌ Don't
- Don't cache user-specific data with global keys - Include user ID in key:
$"cart-{userId}" - Don't use extremely long cache times - Unless data truly never changes
- Don't cache sensitive data unnecessarily - Consider security implications
- Don't forget about memory usage - Very large cached objects can impact performance
- Don't use for real-time data - Use SignalR/WebSockets instead
Performance Characteristics
| Scenario | Without LazyLoad | With LazyLoad | Improvement |
|---|---|---|---|
| 10 simultaneous requests | 10 API calls | 1 API call | 90% reduction |
| Repeated requests (within cache) | N API calls | 1 API call | ~100% reduction |
| Code complexity | 15+ lines (manual) | 2 lines | 85% reduction |
| Memory overhead | Manual dictionaries | Managed cache | Automatic cleanup |
Integration with Other Async Helpers
LazyLoad + AsyncData<T>
async Task LoadUser(int userId)
{
await Update(s => s with { User = s.User.ToLoading() });
try
{
// Combine caching with loading state management
var user = await LazyLoad(
$"user-{userId}",
() => UserService.GetUserAsync(userId),
cacheFor: TimeSpan.FromMinutes(5));
await Update(s => s with { User = AsyncData.Success(user) });
}
catch (Exception ex)
{
await Update(s => s with { User = AsyncData.Failure(ex.Message) });
}
}
LazyLoad + ExecuteAsync
async Task LoadProduct(int productId)
{
await ExecuteAsync(
// LazyLoad inside ExecuteAsync for automatic error handling + caching
() => LazyLoad(
$"product-{productId}",
() => ProductService.GetAsync(productId),
cacheFor: TimeSpan.FromMinutes(5)),
loading: s => s with { Product = s.Product.ToLoading() },
success: (s, product) => s with { Product = AsyncData.Success(product) },
error: (s, ex) => s with { Product = AsyncData.Failure(ex.Message) }
);
}
Common Use Cases
- User profiles - Cache user data across multiple components
- Product catalogs - Cache product lists, categories, filters
- Reference data - Countries, states, categories (rarely changes)
- API responses - Any expensive API call that can be cached
- Search results - Cache search queries to avoid duplicate searches
- Master-detail views - Cache master list while details load
- Dashboard data - Cache dashboard widgets for performance
- Configuration - App settings, feature flags, etc.
Troubleshooting
LazyLoad not available?
// ✅ Register utilities in Program.cs
builder.Services.AddStoreUtilities();
// OR use the all-in-one registration
builder.Services.AddStoreWithUtilities(...);
Data not updating after mutation?
@inject ILazyCache Cache
async Task DeleteItem(int id)
{
await ItemService.DeleteAsync(id);
// Invalidate cache so next load gets fresh data
Cache.Invalidate($"item-{id}");
Cache.Invalidate("items-list"); // If cached list exists
}
Multiple requests still being made?
Ensure you're using the same cache key across all requests. Keys must match exactly:
// ❌ Different keys = different cache entries
await LazyLoad("user-123", ...);
await LazyLoad("User-123", ...); // Case sensitive!
// ✅ Same key = shared cache
await LazyLoad($"user-{userId}", ...);
await LazyLoad($"user-{userId}", ...);