🎯 What You'll Learn
- Creating reusable fetch wrappers
- Timeout & AbortController patterns
- Retry with exponential backoff
- Auth token injection
- Response caching & deduplication
- Building an OOP API client
đź’ˇ Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:
- Download Node.js to run JavaScript on your computer
- Use your browser's Developer Console (Press F12) to test code snippets
- Create a
.htmlfile with<script>tags and open it in your browser
Building Custom APIs with Fetch & Async Logic
Master the art of designing clean, reusable API layers using fetch, async/await, and production-grade patterns used by professional developers.
Understanding the Role of an API Layer
An API layer acts as the "communication brain" of your application. Instead of writing dozens of repetitive fetch calls scattered throughout your codebase, you centralize everything into a reusable system. This architecture is used in frameworks like Axios, React Query, tRPC, and enterprise-grade SDKs.
A well-designed API layer helps with:
- Reducing duplicated code — write fetch logic once
- Adding global headers — auth tokens, content types
- Handling JSON parsing safely — prevent crashes
- Managing errors centrally — consistent error messages
- Retrying failed requests — improve reliability
- Aborting slow requests — prevent UI hangs
- Caching responses — boost performance
Creating a Base Fetch Wrapper
Let's start with a base utility that handles automatic JSON conversion, standardized error messages, status code interpretation, and default headers. This eliminates 90% of the boilerplate beginners write.
Base Fetch Wrapper
Create a reusable API request utility
export async function apiRequest(url, options = {}) {
try {
const response = await fetch(url, {
headers: {
"Content-Type": "application/json",
...(options.headers || {})
},
...options
});
const contentType = response.headers.get("Content-Type");
const body = contentType?.includes("application/json")
? await response.json()
: await response.text();
if (!response.ok) {
throw new Error(
`Request failed (${response.s
...Adding Timeout Support (AbortController)
Sometimes APIs freeze or respond too slowly, causing the UI to hang. The AbortController API lets you cancel requests that take too long. This is essential for production apps where network conditions are unpredictable.
Timeout Support
Implement request timeout with AbortController
function fetchWithTimeout(url, ms = 7000) {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal }).finally(() =>
clearTimeout(id)
);
}
// Usage - abort after 3 seconds
fetchWithTimeout("https://jsonplaceholder.typicode.com/users", 3000)
.then(res => res.json())
.then(data => console.log("Loaded:", data.length, "users"))
.catch(err => {
if (err.name === "AbortError") {
console.lo
...Adding Automatic Retries with Exponential Backoff
Networks fail—especially on mobile. Retries with exponential backoff dramatically improve reliability. This technique is used in Stripe, Shopify, Google APIs, and most major SDKs. The delay doubles after each failed attempt (300ms → 600ms → 1200ms).
Automatic Retries
Implement exponential backoff retry logic
async function retryFetch(fn, retries = 3, delay = 300) {
try {
return await fn();
} catch (err) {
if (retries === 0) throw err;
console.log(`Retrying... ${retries} attempts left`);
await new Promise(r => setTimeout(r, delay));
return retryFetch(fn, retries - 1, delay * 2);
}
}
// Usage with exponential backoff
retryFetch(() =>
fetch("https://jsonplaceholder.typicode.com/posts/1")
.then(r => r.json())
)
.then(data => console.log("Success:", data.title))
.cat
...Creating an API Client with Token Support
Most real APIs require authentication (JWT, OAuth, API keys). Here's an authentication-aware wrapper that automatically injects the stored token into every request.
Token Support
API client with authentication
async function apiClient(path, options = {}) {
const token = localStorage.getItem("token");
const response = await fetch(path, {
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
...options.headers
},
...options
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
return response.json();
}
// Simulate storing a token
localStorage.setItem("token", "my-secret-jwt-token");
// Now al
...Caching Responses for Faster Performance
Caching results locally can reduce API load and make your UI feel instant. This memory cache prevents repeated network calls for unchanged data.
Response Caching
Implement in-memory response caching
const memoryCache = new Map();
async function cachedRequest(key, fetcher) {
if (memoryCache.has(key)) {
console.log("Cache hit for:", key);
return memoryCache.get(key);
}
console.log("Cache miss, fetching:", key);
const data = await fetcher();
memoryCache.set(key, data);
return data;
}
// First call - fetches from network
cachedRequest("users", () =>
fetch("https://jsonplaceholder.typicode.com/users").then(r => r.json())
).then(users => console.log("First call:", users.l
...Data Normalization & Transformers
Sometimes an API returns data in a structure that's not ideal for your UI. Instead of refactoring your components every time, you can normalize data inside the API client. This pattern is used by Redux Toolkit, Prisma, and GraphQL clients.
Data Normalization
Transform API responses for your UI
function normalizeUser(raw) {
return {
id: raw.id,
name: raw.name,
email: raw.email,
isActive: raw.status === "active" || true,
company: raw.company?.name || "Unknown"
};
}
async function getUser(id) {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
const data = await response.json();
return normalizeUser(data);
}
// Get normalized user data
getUser(1).then(user => {
console.log("Normalized user:", user);
console.log("Company:
...Batching Multiple Requests in Parallel
Often you need to load several resources at once—user data, analytics, settings, notifications. Using Promise.all() instead of sequential awaits dramatically reduces waiting time and is essential for dashboards and homepages.
Parallel Requests
Batch multiple API calls with Promise.all
// Sequential (slow) - each waits for the previous
async function loadSequential() {
console.time("Sequential");
const users = await fetch("https://jsonplaceholder.typicode.com/users").then(r => r.json());
const posts = await fetch("https://jsonplaceholder.typicode.com/posts").then(r => r.json());
const comments = await fetch("https://jsonplaceholder.typicode.com/comments").then(r => r.json());
console.timeEnd("Sequential");
return { users, posts, comments };
}
// Parallel (fast) -
...Implementing a Global API Error Handler
Instead of manually catching errors everywhere, create a single centralized handler that maps error types to user-friendly messages. This keeps your UI clean and consistent.
Global Error Handler
Centralized API error handling
function handleApiError(error) {
if (error.name === "AbortError") {
return "Request timed out. Please try again.";
}
if (error.message.includes("401")) {
return "You are not authorized. Please log in.";
}
if (error.message.includes("404")) {
return "Resource not found.";
}
if (error.message.includes("500")) {
return "Server error. Please try again later.";
}
return "An unexpected error occurred.";
}
// Test with different scenarios
async function testErrorHandl
...Creating Reusable API Classes
For even more structure, advanced developers wrap everything inside classes. This pattern makes every API call clean and semantic, following the same approach used in large-scale frontends.
Reusable API Classes
Object-oriented API client design
class Api {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async get(path) {
const response = await fetch(`${this.baseUrl}${path}`);
if (!response.ok) throw new Error(`GET failed: ${response.status}`);
return response.json();
}
async post(path, body) {
const response = await fetch(`${this.baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) throw new Error(
...Handling Paginated APIs
Real APIs often return paginated data. Here's a reusable paginator that fetches multiple pages and combines the results—perfect for tables, infinite scroll, and analytic dashboards.
Pagination Handling
Fetch and combine paginated API data
async function fetchPage(url, page = 1, limit = 10) {
const response = await fetch(`${url}?_page=${page}&_limit=${limit}`);
return response.json();
}
async function fetchAllPages(url, maxPages = 3) {
const results = [];
for (let i = 1; i <= maxPages; i++) {
console.log(`Fetching page ${i}...`);
const page = await fetchPage(url, i, 5);
if (page.length === 0) break;
results.push(...page);
}
return results;
}
// Fetch first 3 pages of posts (5 per page)
fetchAllPages
...Creating an API Cache With Expiry (TTL)
To prevent stale data, add expiry times (Time-To-Live) to your cache entries. This ensures data stays fresh while still reducing unnecessary network requests.
Cache with Expiry
Implement TTL-based response caching
const timedCache = new Map();
function cacheSet(key, value, ttl = 60000) {
timedCache.set(key, { value, expiry: Date.now() + ttl });
console.log(`Cached "${key}" for ${ttl/1000}s`);
}
function cacheGet(key) {
const entry = timedCache.get(key);
if (!entry) {
console.log(`Cache miss: "${key}"`);
return null;
}
if (Date.now() > entry.expiry) {
timedCache.delete(key);
console.log(`Cache expired: "${key}"`);
return null;
}
console.log(`Cache hit: "${key}"`);
re
...Smart Request Deduplication
Imagine multiple components requesting the same data at the same time. Instead of sending the same HTTP request repeatedly, deduplication ensures only one network call happens. This prevents duplicate traffic and speeds load times dramatically.
Request Deduplication
Prevent duplicate concurrent requests
const pending = new Map();
async function dedupedFetch(url) {
if (pending.has(url)) {
console.log("Reusing pending request for:", url);
return pending.get(url);
}
console.log("Starting new request for:", url);
const promise = fetch(url)
.then(r => r.json())
.finally(() => pending.delete(url));
pending.set(url, promise);
return promise;
}
// Simulate multiple components requesting the same data
const url = "https://jsonplaceholder.typicode.com/users";
// All thr
...Building a Universal API Wrapper
Here's a complete, production-grade API wrapper that includes retries, timeouts, aborting, caching, JSON parsing, error mapping, and data transformation. This is the same architecture used by professional engineering teams.
Universal API Wrapper
Production-grade API client with all features
async function universalAPI(url, {
method = "GET",
body = null,
headers = {},
timeout = 8000,
retries = 3,
cache = false,
transform = (x) => x
} = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
// Check cache first
if (cache && sessionStorage.getItem(url)) {
console.log("Returning cached data");
clearTimeout(timer);
return JSON.parse(sessionStorage.getItem(url));
}
for (let attempt = 0; attempt
...Security Considerations
⚠️ Security Warning: Browser storage is not secure for sensitive data.
- Never store passwords — hash on the server, not the client
- Sanitize user input — prevent XSS and injection attacks
- Validate server responses — don't trust external APIs blindly
- Limit localStorage data — store tokens only, no sensitive user data
- Use HTTPS everywhere — modern browsers block insecure API calls
What You Learned
Core Patterns
- âś” Building API layers
- âś” Creating fetch wrappers
- âś” Using async/await effectively
- âś” Handling errors correctly
- âś” Adding timeouts
- âś” Implementing retries
Advanced Techniques
- âś” Injecting auth tokens
- âś” Caching with expiration
- âś” Request deduplication
- âś” Parallel requests
- âś” Pagination handling
- âś” Universal API wrappers
Practice Challenges
1. Build a Complete API Client
Create an API client that combines timeout, retry, caching, and token injection into a single reusable module.
2. Implement Optimistic UI Updates
Build a "like" button that updates instantly, then syncs with the server in the background with rollback on failure.
3. Create a Request Queue
Build a system that ensures only one API request runs at a time to prevent rate limiting and server overload.
4. Design a Stale-While-Revalidate Cache
Serve cached data instantly while fetching fresh data in the background, then update the UI when new data arrives.
🎉 Lesson Complete!
You now know how to build enterprise-grade API clients with retries, caching, and token injection. Next up: Error Handling Patterns.
Sign up for free to track which lessons you've completed and get learning reminders.