Angular Caching
Introduction
Caching is a crucial performance optimization technique that involves storing frequently accessed data temporarily to avoid expensive repeat operations. In Angular applications, caching can significantly improve user experience by reducing load times, decreasing network requests, and minimizing server load.
In this tutorial, we'll explore different caching strategies in Angular, from basic in-memory caching to more advanced techniques using the HttpClient and RxJS. By the end of this guide, you'll understand how to implement effective caching mechanisms that balance freshness of data with application performance.
Why Caching Matters in Angular
Before diving into implementation, let's understand why caching is essential for Angular applications:
- Reduced Network Requests: Fewer HTTP calls mean faster application response times
- Lower Server Load: Decreased requests to your backend services
- Improved User Experience: Instantaneous data display from cache
- Offline Capabilities: Some caching strategies enable offline functionality
- Bandwidth Savings: Particularly important for mobile users
Basic In-Memory Caching
The simplest form of caching in Angular is in-memory caching using services. Let's create a basic cache service:
// cache.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class CacheService {
private cache = new Map<string, any>();
// Store data in cache
set(key: string, data: any): void {
this.cache.set(key, data);
}
// Retrieve data from cache
get(key: string): any {
return this.cache.get(key);
}
// Check if key exists in cache
has(key: string): boolean {
return this.cache.has(key);
}
// Remove specific item from cache
remove(key: string): boolean {
return this.cache.delete(key);
}
// Clear entire cache
clear(): void {
this.cache.clear();
}
}
Now, let's use this service in a data service that fetches user information:
// user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
import { CacheService } from './cache.service';
import { User } from './user.model';
@Injectable({
providedIn: 'root'
})
export class UserService {
private apiUrl = 'https://api.example.com/users';
constructor(
private http: HttpClient,
private cacheService: CacheService
) {}
getUser(id: number): Observable<User> {
const cacheKey = `user-${id}`;
// Check if data exists in cache
if (this.cacheService.has(cacheKey)) {
console.log('Returning cached user data');
return of(this.cacheService.get(cacheKey));
}
// If not in cache, fetch from API and store in cache
console.log('Fetching user data from API');
return this.http.get<User>(`${this.apiUrl}/${id}`).pipe(
tap(user => this.cacheService.set(cacheKey, user))
);
}
}
In this example:
- We first check if the requested user data exists in our cache using a unique key
- If found, we return the cached data without making an HTTP request
- If not found, we fetch the data from the API and store it in the cache before returning it
This approach works well for static data that doesn't change frequently.
Advanced HTTP Caching with Interceptors
Angular's HTTP interceptors provide a more sophisticated way to implement caching. Let's create a cache interceptor:
// cache.interceptor.ts
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor,
HttpResponse
} from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap, shareReplay } from 'rxjs/operators';
import { CacheService } from './cache.service';
@Injectable()
export class CacheInterceptor implements HttpInterceptor {
private readonly cachableUrls = [
'api.example.com/users',
'api.example.com/products'
];
constructor(private cacheService: CacheService) {}
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
// Only cache GET requests
if (request.method !== 'GET') {
return next.handle(request);
}
// Check if URL should be cached
const shouldCache = this.cachableUrls.some(url => request.url.includes(url));
if (!shouldCache) {
return next.handle(request);
}
const cacheKey = request.url;
const cachedResponse = this.cacheService.get(cacheKey);
// Return cached response if available
if (cachedResponse) {
console.log(`Using cached response for: ${request.url}`);
return of(cachedResponse);
}
// Otherwise, send request and cache response
return next.handle(request).pipe(
tap(event => {
if (event instanceof HttpResponse) {
console.log(`Caching response for: ${request.url}`);
this.cacheService.set(cacheKey, event);
}
}),
// shareReplay ensures if multiple subscribers request at the same time,
// only one request will be made
shareReplay(1)
);
}
}
Register your interceptor in the app module:
// app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { AppComponent } from './app.component';
import { CacheInterceptor } from './cache.interceptor';
@NgModule({
declarations: [AppComponent],
imports: [BrowserModule, HttpClientModule],
providers: [
{ provide: HTTP_INTERCEPTORS, useClass: CacheInterceptor, multi: true }
],
bootstrap: [AppComponent]
})
export class AppModule {}
This approach is more powerful because:
- It transparently intercepts all HTTP requests
- You can configure which API endpoints should be cached
- It works seamlessly with existing services without modifying their code
Time-Based Cache Expiration
Most caching implementations need expiration logic. Let's enhance our CacheService:
// cache.service.ts (with expiration)
import { Injectable } from '@angular/core';
interface CacheItem {
data: any;
expiry: number;
}
@Injectable({
providedIn: 'root'
})
export class CacheService {
private cache = new Map<string, CacheItem>();
// Default cache lifetime in milliseconds (5 minutes)
private defaultTTL = 5 * 60 * 1000;
// Store data in cache with expiration
set(key: string, data: any, ttl = this.defaultTTL): void {
const expiry = Date.now() + ttl;
this.cache.set(key, { data, expiry });
}
// Get data if not expired
get(key: string): any {
const item = this.cache.get(key);
// Return null if item doesn't exist
if (!item) return null;
// Check if item has expired
if (Date.now() > item.expiry) {
this.remove(key);
return null;
}
return item.data;
}
has(key: string): boolean {
return this.get(key) !== null;
}
remove(key: string): boolean {
return this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
}
Real-World Application: Caching API Responses
Let's implement a complete example with a ProductService that displays a product catalog:
// product.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { tap, catchError } from 'rxjs/operators';
import { CacheService } from './cache.service';
export interface Product {
id: number;
name: string;
price: number;
description: string;
}
@Injectable({
providedIn: 'root'
})
export class ProductService {
private apiUrl = 'https://api.example.com/products';
// Cache duration for different endpoints
private cacheDuration = {
allProducts: 60 * 60 * 1000, // 1 hour for product list
productDetail: 30 * 60 * 1000 // 30 minutes for individual product
};
constructor(
private http: HttpClient,
private cacheService: CacheService
) {}
getProducts(): Observable<Product[]> {
const cacheKey = 'all-products';
const cachedData = this.cacheService.get(cacheKey);
if (cachedData) {
return of(cachedData);
}
return this.http.get<Product[]>(this.apiUrl).pipe(
tap(products => {
this.cacheService.set(cacheKey, products, this.cacheDuration.allProducts);
}),
catchError(error => {
console.error('Error fetching products', error);
return of([]);
})
);
}
getProduct(id: number): Observable<Product | null> {
const cacheKey = `product-${id}`;
const cachedData = this.cacheService.get(cacheKey);
if (cachedData) {
return of(cachedData);
}
return this.http.get<Product>(`${this.apiUrl}/${id}`).pipe(
tap(product => {
this.cacheService.set(cacheKey, product, this.cacheDuration.productDetail);
}),
catchError(error => {
console.error(`Error fetching product ${id}`, error);
return of(null);
})
);
}
// Method to force refresh product data
refreshProduct(id: number): Observable<Product | null> {
const cacheKey = `product-${id}`;
// Remove from cache before fetching
this.cacheService.remove(cacheKey);
return this.getProduct(id);
}
}
Now let's use this in a component:
// product-list.component.ts
import { Component, OnInit } from '@angular/core';
import { ProductService, Product } from './product.service';
@Component({
selector: 'app-product-list',
template: `
<div>
<h2>Product List</h2>
<button (click)="refreshProducts()">Refresh Products</button>
<div *ngIf="loading">Loading products...</div>
<div *ngIf="error">{{ error }}</div>
<ul>
<li *ngFor="let product of products">
{{ product.name }} - ${{ product.price }}
</li>
</ul>
</div>
`
})
export class ProductListComponent implements OnInit {
products: Product[] = [];
loading = false;
error: string | null = null;
constructor(private productService: ProductService) {}
ngOnInit(): void {
this.loadProducts();
}
loadProducts(): void {
this.loading = true;
this.productService.getProducts().subscribe({
next: (data) => {
this.products = data;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load products';
this.loading = false;
}
});
}
refreshProducts(): void {
// Force a refresh by clearing cache first
this.productService.cacheService.remove('all-products');
this.loadProducts();
}
}
In the example above:
- We cache the product list for an hour and individual products for 30 minutes
- We provide a method to force refresh data when needed
- The component displays whether data is being loaded from cache or network
Cache Invalidation Strategies
Cache invalidation is knowing when to clear your cache. Here are some common strategies:
1. Time-Based Expiration (TTL)
We've already implemented this in our examples. Set an expiration time when caching:
// TTL of 10 minutes
this.cacheService.set('my-data', data, 10 * 60 * 1000);
2. Event-Based Invalidation
Clear cache when specific events occur:
// product.service.ts
createProduct(product: Product): Observable<Product> {
return this.http.post<Product>(this.apiUrl, product).pipe(
tap(() => {
// Invalidate the products list cache after creating a new product
this.cacheService.remove('all-products');
})
);
}
3. Manual Invalidation
Provide users with the ability to force refresh:
// In a component
refreshData(): void {
this.cacheService.remove('product-data');
this.loadData();
}
Implementing Cache with LocalStorage for Persistence
For data that should persist between browser sessions, we can extend our cache to use localStorage:
// persistent-cache.service.ts
import { Injectable } from '@angular/core';
interface CacheItem {
data: any;
expiry: number;
}
@Injectable({
providedIn: 'root'
})
export class PersistentCacheService {
private prefix = 'app_cache_';
private defaultTTL = 24 * 60 * 60 * 1000; // 24 hours
set(key: string, data: any, ttl = this.defaultTTL): void {
const item: CacheItem = {
data,
expiry: Date.now() + ttl
};
localStorage.setItem(this.prefix + key, JSON.stringify(item));
}
get(key: string): any {
const itemStr = localStorage.getItem(this.prefix + key);
// Return null if item doesn't exist
if (!itemStr) return null;
const item: CacheItem = JSON.parse(itemStr);
// Check if item has expired
if (Date.now() > item.expiry) {
this.remove(key);
return null;
}
return item.data;
}
has(key: string): boolean {
return this.get(key) !== null;
}
remove(key: string): void {
localStorage.removeItem(this.prefix + key);
}
clear(): void {
// Only clear keys that start with our prefix
Object.keys(localStorage)
.filter(key => key.startsWith(this.prefix))
.forEach(key => localStorage.removeItem(key));
}
}
This cache will survive page refreshes and browser restarts, which is particularly useful for:
- User preferences
- Recently viewed items
- Form data that should persist
- Authentication tokens (though these need extra security measures)
Performance Considerations
When implementing caching, keep these performance considerations in mind:
- Memory Consumption: In-memory caches can grow large, especially with images or large datasets
- Cache Size Limits: Consider implementing a maximum cache size with LRU (Least Recently Used) eviction
- Critical Paths: Prioritize caching for frequently accessed data and performance-critical paths
- Data Freshness: Balance cache duration with how frequently your data changes
- Cache Warming: Pre-populate caches for important data at application startup
Summary
In this tutorial, we've explored various approaches to caching in Angular applications:
- Basic in-memory caching using services
- HTTP interceptors for transparent caching
- Time-based cache expiration
- Event-based cache invalidation
- Persistent caching with localStorage
Implementing effective caching strategies can significantly improve your Angular application's performance and user experience. The key is to find the right balance between data freshness and performance for your specific use case.
Additional Resources
Exercises
- Implement a cache service that uses the browser's IndexedDB for storing larger datasets
- Create a cache that combines both memory and localStorage, using memory for frequent access and localStorage for persistence
- Build a "stale-while-revalidate" caching strategy that returns cached data immediately while refreshing it in the background
- Implement size-based cache eviction to prevent memory leaks in long-running applications
- Create a debug tool that displays cache statistics (hits, misses, size) for performance monitoring
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)