xRetries: number;
}
interface ApiResponse<T> {
data: T;
status: number;
headers: Headers;
}
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
### Step 2: Implement Lifecycle Controls
The native API lacks built-in timeouts and cancellation. Wrap the execution with `AbortController` and `Promise.race` to enforce boundaries.
```typescript
function createTimeoutSignal(timeoutMs: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeoutMs);
return controller.signal;
}
Step 3: Build the Execution Engine
The core function handles method routing, payload serialization, status validation, and retry logic. Notice the explicit response.ok check and structured error throwing.
async function executeRequest<T>(
config: NetworkConfig,
endpoint: string,
method: HttpMethod,
payload?: unknown,
retryCount = 0
): Promise<ApiResponse<T>> {
const url = `${config.baseUrl}${endpoint}`;
const headers = new Headers(config.defaultHeaders);
const requestInit: RequestInit = {
method,
headers,
signal: createTimeoutSignal(config.timeoutMs),
};
if (payload && method !== 'GET') {
headers.set('Content-Type', 'application/json');
requestInit.body = JSON.stringify(payload);
}
try {
const response = await fetch(url, requestInit);
if (!response.ok) {
const errorBody = await response.json().catch(() => null);
throw new NetworkError(response.status, errorBody);
}
const data = await response.json();
return { data, status: response.status, headers: response.headers };
} catch (error) {
if (error instanceof NetworkError) throw error;
if (retryCount < config.maxRetries && isRetryableError(error)) {
await delay(1000 * (retryCount + 1));
return executeRequest(config, endpoint, method, payload, retryCount + 1);
}
throw error;
}
}
class NetworkError extends Error {
constructor(public statusCode: number, public details?: unknown) {
super(`HTTP ${statusCode}: Network request failed`);
}
}
function isRetryableError(error: unknown): boolean {
if (error instanceof TypeError) return true;
if (error instanceof NetworkError && error.statusCode >= 500) return true;
return false;
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
Step 4: Expose Domain-Specific Clients
Abstract the engine behind typed methods. This prevents configuration drift and enforces consistent usage across the application.
class ResourceClient {
constructor(private config: NetworkConfig) {}
async get<T>(path: string): Promise<ApiResponse<T>> {
return executeRequest<T>(this.config, path, 'GET');
}
async create<T>(path: string, payload: unknown): Promise<ApiResponse<T>> {
return executeRequest<T>(this.config, path, 'POST', payload);
}
async update<T>(path: string, payload: unknown): Promise<ApiResponse<T>> {
return executeRequest<T>(this.config, path, 'PUT', payload);
}
async remove<T>(path: string): Promise<ApiResponse<T>> {
return executeRequest<T>(this.config, path, 'DELETE');
}
}
Architecture Decisions & Rationale
- Explicit Status Validation:
fetch() resolves for all HTTP responses. Checking response.ok prevents silent 4xx/5xx propagation.
- AbortController Integration: Native timeouts don't exist. Wrapping with
setTimeout and AbortController prevents zombie requests from consuming memory or triggering state updates after component unmount.
- Retry Logic with Exponential Backoff: Transient network failures (DNS timeouts, gateway errors) resolve quickly. Retrying with a delay improves resilience without overwhelming the server.
- Generic Response Envelope: Returning
{ data, status, headers } preserves metadata while keeping business logic focused on the payload.
- Centralized Error Transformation: Custom
NetworkError class standardizes error handling across the application, enabling consistent UI feedback and logging.
Pitfall Guide
1. Assuming fetch Rejects on HTTP Errors
Explanation: The API only rejects on network failures. A 404 or 500 response resolves successfully, causing .then() or await to proceed as if the request succeeded.
Fix: Always validate response.ok or check response.status before parsing. Throw a custom error if the status falls outside the 2xx range.
2. Omitting Content-Type for JSON Payloads
Explanation: Servers expect explicit media types. Sending a stringified object without application/json often results in 415 Unsupported Media Type or malformed parsing on the backend.
Fix: Set headers.set('Content-Type', 'application/json') whenever serializing payloads with JSON.stringify().
3. Neglecting Request Cancellation
Explanation: Components unmount or routes change while requests are in flight. Without cancellation, stale responses update unmounted state, causing memory leaks and React/Vue warnings.
Fix: Use AbortController tied to component lifecycle or route navigation. Pass controller.signal to the fetch options and call controller.abort() on cleanup.
4. Blocking Execution with Large JSON Parsing
Explanation: response.json() loads the entire payload into memory before parsing. Large responses (e.g., >10MB) can freeze the main thread or trigger out-of-memory errors.
Fix: For large datasets, stream the response using response.body.getReader() and process chunks incrementally, or implement server-side pagination.
5. Ignoring CORS Preflight Requirements
Explanation: Browsers block cross-origin requests that use custom headers or non-simple methods (PUT, DELETE, PATCH) by sending an OPTIONS preflight first. If the server doesn't respond correctly, the actual request never executes.
Fix: Ensure the backend sends Access-Control-Allow-Origin, Access-Control-Allow-Methods, and Access-Control-Allow-Headers matching the client request. Test preflight behavior using browser dev tools.
6. Hardcoding Credentials and Base URLs
Explanation: Embedding API keys or environment-specific URLs directly in request logic creates security vulnerabilities and deployment friction.
Fix: Inject configuration via environment variables or a centralized config service. Never expose secret keys in client-side code; use proxy endpoints or signed tokens instead.
7. Missing Timeout Enforcement
Explanation: Network requests can hang indefinitely due to server overload or routing issues. Unbounded requests degrade perceived performance and block UI threads.
Fix: Implement a timeout wrapper using AbortController and Promise.race. Set reasonable defaults (e.g., 5-10 seconds) based on endpoint complexity.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple CRUD app with few endpoints | Raw fetch with async/await | Minimal overhead, direct control | Low (development time) |
| Large-scale SPA with complex state | Typed wrapper + centralized client | Consistency, reusability, error boundaries | Medium (initial setup) |
| Data-heavy dashboards with caching | React Query / SWR / Apollo | Built-in caching, deduplication, background refetch | High (learning curve, bundle size) |
| Legacy browser support required | Axios or polyfilled fetch | Native fetch lacks IE11 support, Axios handles transforms automatically | Medium (polyfill maintenance) |
| Real-time streaming or large files | Native fetch + ReadableStream | Libraries abstract streaming; native API provides chunk-level control | Low (implementation complexity) |
Configuration Template
// network/client.ts
export interface ApiClientConfig {
baseUrl: string;
timeoutMs?: number;
maxRetries?: number;
headers?: Record<string, string>;
}
export class ApiClient {
private config: Required<ApiClientConfig>;
constructor(config: ApiClientConfig) {
this.config = {
baseUrl: config.baseUrl.replace(/\/$/, ''),
timeoutMs: config.timeoutMs ?? 8000,
maxRetries: config.maxRetries ?? 2,
headers: config.headers ?? {},
};
}
private async request<T>(
endpoint: string,
method: string,
body?: unknown
): Promise<T> {
const url = `${this.config.baseUrl}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs);
const init: RequestInit = {
method,
headers: {
Accept: 'application/json',
...this.config.headers,
...(body ? { 'Content-Type': 'application/json' } : {}),
},
signal: controller.signal,
...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {}),
};
try {
const response = await fetch(url, init);
clearTimeout(timeoutId);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`[${response.status}] ${response.statusText}: ${JSON.stringify(errorData)}`);
}
return response.json();
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
throw new Error('Request timed out');
}
throw err;
}
}
get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, 'GET');
}
post<T>(endpoint: string, payload: unknown): Promise<T> {
return this.request<T>(endpoint, 'POST', payload);
}
put<T>(endpoint: string, payload: unknown): Promise<T> {
return this.request<T>(endpoint, 'PUT', payload);
}
patch<T>(endpoint: string, payload: unknown): Promise<T> {
return this.request<T>(endpoint, 'PATCH', payload);
}
delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, 'DELETE');
}
}
Quick Start Guide
- Initialize the Client: Import
ApiClient and instantiate it with your environment's base URL and optional timeout/retry settings.
const api = new ApiClient({ baseUrl: 'https://api.yourdomain.com/v1' });
- Define Response Types: Create TypeScript interfaces matching your backend payload structure to enable compile-time validation.
interface User { id: number; name: string; email: string; }
- Execute Typed Requests: Call the appropriate method (
get, post, etc.) with the endpoint and generic type. Wrap in try/catch for error handling.
try {
const users = await api.get<User[]>('/users');
console.log('Loaded:', users);
} catch (err) {
console.error('Network failure:', err);
}
- Integrate Cancellation: When used in component lifecycles, store the
AbortController instance and call .abort() during cleanup to prevent memory leaks and stale state updates.
// Example cleanup hook
useEffect(() => {
const controller = new AbortController();
// Pass controller.signal to fetch options
return () => controller.abort();
}, []);