Single Page Applications leaking memory from unfinished fetch requests? AbortController is your solution to clean up abandoned API calls.
The Memory Leak Problem:
// Common React/Vue pattern causing leaks
async function fetchUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUserData(data); // State update
}
// User clicks between tabs quickly:
// Tab 1: fetchUserData(1) - starts
// Tab 2: fetchUserData(2) - starts before #1 finishes
// Tab 3: fetchUserData(3) - starts before #1 and #2 finish
// Result: 3 parallel requests, but only last one matters
// Previous requests continue, waste bandwidth, cause race conditions
// Memory leak: response handlers stay in memory
Solution: AbortController Pattern
// Create abort controller for each request
let controller = new AbortController();
async function fetchUserDataSafe(userId) {
// Cancel previous request if still running
if (controller) {
controller.abort();
}
// Create new controller for this request
controller = new AbortController();
try {
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal // Attach abort signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setUserData(data);
} catch (error) {
// Check if error is from abort (not network error)
if (error.name === 'AbortError') {
console.log('Request was aborted');
return; // Don't show error for aborted requests
}
// Handle real errors
console.error('Fetch error:', error);
showError(error.message);
}
}
React Hook Implementation:
import { useEffect, useRef } from 'react';
function useAbortableFetch() {
const controllers = useRef(new Map());
const fetchWithAbort = async (url, options = {}, key = 'default') => {
// Cancel previous request with same key
if (controllers.current.has(key)) {
controllers.current.get(key).abort();
}
const controller = new AbortController();
controllers.current.set(key, controller);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
controllers.current.delete(key); // Clean up
return { success: true, data };
} catch (error) {
controllers.current.delete(key); // Clean up
if (error.name === 'AbortError') {
return { success: false, aborted: true };
}
return { success: false, error };
}
};
// Cleanup on unmount
useEffect(() => {
return () => {
controllers.current.forEach(controller => controller.abort());
controllers.current.clear();
};
}, []);
return fetchWithAbort;
}
// Usage in component
function UserProfile({ userId }) {
const fetchWithAbort = useAbortableFetch();
const [user, setUser] = useState(null);
useEffect(() => {
const loadUser = async () => {
const result = await fetchWithAbort(
`/api/users/${userId}`,
{},
`user-${userId}` // Unique key per user
);
if (result.success) {
setUser(result.data);
} else if (!result.aborted) {
console.error('Failed:', result.error);
}
};
loadUser();
// Cleanup: abort when userId changes or component unmounts
return () => {
// Already handled by hook
};
}, [userId, fetchWithAbort]);
return {/* render */};
}
Advanced: Timeout with AbortController
// Add timeout to fetch requests
async function fetchWithTimeout(url, options = {}, timeout = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
}
}
// Usage with retry logic
async function fetchWithRetry(url, retries = 3, timeout = 5000) {
for (let i = 0; i < retries; i++) {
try {
return await fetchWithTimeout(url, {}, timeout);
} catch (error) {
if (i === retries - 1) throw error;
// Exponential backoff
const delay = Math.pow(2, i) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
console.log(`Retry ${i + 1}/${retries} for ${url}`);
}
}
}
Real-World SPA Implementation:
// Dashboard with multiple real-time updates
class SafeDataFetcher {
constructor() {
this.controllers = new Map();
this.cache = new Map();
this.cacheTime = new Map();
}
async fetchData(endpoint, params = {}, cacheDuration = 60000) {
const cacheKey = `${endpoint}:${JSON.stringify(params)}`;
// Return cached data if valid
const cachedTime = this.cacheTime.get(cacheKey);
if (cachedTime && Date.now() - cachedTime < cacheDuration) {
return this.cache.get(cacheKey);
}
// Cancel previous request for same endpoint
if (this.controllers.has(cacheKey)) {
this.controllers.get(cacheKey).abort();
}
const controller = new AbortController();
this.controllers.set(cacheKey, controller);
try {
const queryString = new URLSearchParams(params).toString();
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
const response = await fetch(url, {
signal: controller.signal,
headers: {
'Accept': 'application/json',
'Cache-Control': 'no-cache'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Cache the result
this.cache.set(cacheKey, data);
this.cacheTime.set(cacheKey, Date.now());
// Clean up controller
this.controllers.delete(cacheKey);
return data;
} catch (error) {
this.controllers.delete(cacheKey);
if (error.name === 'AbortError') {
console.log(`Request for ${cacheKey} was aborted`);
return null;
}
// Re-throw for caller to handle
throw error;
}
}
abortAll() {
this.controllers.forEach(controller => controller.abort());
this.controllers.clear();
}
clearCache() {
this.cache.clear();
this.cacheTime.clear();
}
}
// Usage in dashboard
const fetcher = new SafeDataFetcher();
// Multiple concurrent safe requests
Promise.all([
fetcher.fetchData('/api/stats', { period: 'daily' }),
fetcher.fetchData('/api/users/active'),
fetcher.fetchData('/api/orders/recent')
])
.then(([stats, users, orders]) => {
updateDashboard({ stats, users, orders });
})
.catch(error => {
console.error('Dashboard load failed:', error);
});
// Clean up when leaving dashboard
window.addEventListener('beforeunload', () => {
fetcher.abortAll();
});
