Implement Fetch API Wrapper & AbortController
Interview Question: "Build a fetch wrapper with timeout, retry, and cancellation support."
Minimal XMLHttpRequest-Based Fetch Polyfill
function simpleFetch(url, options = {}) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(options.method || 'GET', url);
if (options.headers) {
Object.entries(options.headers).forEach(([key, value]) => {
xhr.setRequestHeader(key, value);
});
}
xhr.onload = () => {
const response = {
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
statusText: xhr.statusText,
headers: parseHeaders(xhr.getAllResponseHeaders()),
json: () => Promise.resolve(JSON.parse(xhr.responseText)),
text: () => Promise.resolve(xhr.responseText),
};
resolve(response);
};
xhr.onerror = () => reject(new TypeError('Network request failed'));
xhr.ontimeout = () => reject(new TypeError('Network request timed out'));
xhr.send(options.body ?? null);
});
}
function parseHeaders(raw) {
const headers = new Map();
raw.trim().split('\r\n').forEach(line => {
const [key, ...rest] = line.split(': ');
headers.set(key.toLowerCase(), rest.join(': '));
});
return {
get: (name) => headers.get(name.toLowerCase()),
has: (name) => headers.has(name.toLowerCase()),
};
}Production Fetch Wrapper
interface FetchOptions extends RequestInit {
timeout?: number;
retries?: number;
retryDelay?: number | ((attempt: number) => number);
onRetry?: (error: Error, attempt: number) => void;
}
async function fetchWithRetry(
url: string,
options: FetchOptions = {}
): Promise<Response> {
const {
timeout = 10_000,
retries = 3,
retryDelay = (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
onRetry,
...fetchOptions
} = options;
let lastError: Error;
for (let attempt = 0; attempt <= retries; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
if (fetchOptions.signal) {
fetchOptions.signal.addEventListener('abort', () => controller.abort());
}
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok && attempt < retries && response.status >= 500) {
throw new Error(`HTTP ${response.status}`);
}
return response;
} catch (error) {
clearTimeout(timeoutId);
lastError = error as Error;
if (error instanceof DOMException && error.name === 'AbortError') {
if (fetchOptions.signal?.aborted) throw error;
}
if (attempt < retries) {
const delay = typeof retryDelay === 'function'
? retryDelay(attempt)
: retryDelay;
onRetry?.(lastError, attempt + 1);
await new Promise(r => setTimeout(r, delay));
}
}
}
throw lastError!;
}AbortController Implementation
class SimpleAbortController {
constructor() {
this.signal = new SimpleAbortSignal();
}
abort(reason) {
this.signal._abort(reason);
}
}
class SimpleAbortSignal {
constructor() {
this.aborted = false;
this.reason = undefined;
this._listeners = [];
}
_abort(reason) {
if (this.aborted) return;
this.aborted = true;
this.reason = reason ?? new DOMException('The operation was aborted', 'AbortError');
this._listeners.forEach(fn => fn());
}
addEventListener(type, listener) {
if (type === 'abort') this._listeners.push(listener);
}
removeEventListener(type, listener) {
if (type === 'abort') {
this._listeners = this._listeners.filter(fn => fn !== listener);
}
}
throwIfAborted() {
if (this.aborted) throw this.reason;
}
static timeout(ms) {
const controller = new SimpleAbortController();
setTimeout(() => controller.abort(
new DOMException('The operation timed out', 'TimeoutError')
), ms);
return controller.signal;
}
}Usage Patterns
Cancelling on Component Unmount
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
fetchWithRetry(`/api/users/${userId}`, {
signal: controller.signal,
retries: 2,
timeout: 5000,
})
.then(res => res.json())
.then(setUser)
.catch(err => {
if (err.name !== 'AbortError') console.error(err);
});
return () => controller.abort();
}, [userId]);
return user ? <div>{user.name}</div> : <div>Loading...</div>;
}Race Pattern — First Response Wins
async function fetchFastest(urls) {
const controller = new AbortController();
try {
const result = await Promise.any(
urls.map(url =>
fetch(url, { signal: controller.signal }).then(res => {
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
})
)
);
controller.abort();
return result;
} catch {
throw new Error('All requests failed');
}
}Common Mistakes
- Not clearing the timeout after successful fetch (memory leak)
- Retrying on 4xx errors (client errors shouldn't be retried)
- Not forwarding the external AbortSignal to the internal controller
- Forgetting to check
error.name === 'AbortError'before retrying (abort shouldn't trigger retry) - Using linear retry delays instead of exponential backoff with jitter