Building Custom APIs

Reviewed & published by Brayan K

Master the art of designing clean, reusable API layers using fetch, async/await, and production-grade patterns used by professional developers.

Part of the free JavaScript course at LearnCodingFast β€” hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

🎯 What You'll Learn

πŸ’‘ Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:

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:

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.

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.status}): ${
          body?.message || JSON.stringify(body)
        }`
      );
    }

    return body;
  } catch (error) {
    console.error("API request error:", error);
    throw error;
  }
}

// Usage
async function loadUsers() {
  const users = await apiRequest("https://jsonplaceholder.typicode.com/users");
  console.log(users);
}

loadUsers();

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.

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.log("Request timed out!");
    } else {
      console.error("Error:", err);
    }
  });

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).

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))
  .catch(err => console.error("All retries failed:", err));

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.

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 all requests include the Authorization header
apiClient("https://jsonplaceholder.typicode.com/users/1")
  .then(user => console.log("User:", user.name))
  .catch(console.error);

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.

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.length, "users"));

// Second call - returns from cache
setTimeout(() => {
  cachedRequest("users", () =>
    fetch("https://jsonplaceholder.typicode.com/users").then(r => r.json())
  ).then(users => console.log("Second call (cached):", users.length, "users"));
}, 1000);

Worked Example: A Cache You Can Actually Watch Expire

A cache that expires against the real clock is almost impossible to demonstrate β€” you would have to sit and wait, and the result would differ every run. So pass the clock in as a function. Now you can jump forward in time, watch a hit turn into an expiry, and count exactly how many network calls the cache saved. This is also how caches get unit-tested in production code: inject the clock, never read the real one.

Every non-obvious line is commented with what it prints. Read it, then run it.

// The caching layer of an API client β€” with the clock in your hands.
// A real cache expires against Date.now(), which makes it impossible to
// demonstrate without sitting and waiting. Pass the clock IN as a function
// and you can jump forward in time. This is also exactly how you unit-test
// a cache: inject the clock, never read the real one.

function createCache({ ttlMs, now }) {
  const store = new Map();   // key -> { value, expiresAt }

  return {
    set(key, value) {
      store.set(key, { value, expiresAt: now() + ttlMs });
      console.log("cached " + key + " until t=" + (now() + ttlMs));
    },

    get(key) {
      const entry = store.get(key);

      if (!entry) {
        console.log("MISS " + key + " (never cached)");
        return null;
      }

      if (now() >= entry.expiresAt) {
        store.delete(key);                          // evict on read β€” no timers needed
        console.log("EXPIRED " + key + " at t=" + now());
        return null;
      }

      console.log("HIT " + key + " at t=" + now());
      return entry.value;
    },

    size() { return store.size; }
  };
}

// A fake clock. clock.t is "the current time" and you move it by hand.
const clock = { t: 0 };
const cache = createCache({ ttlMs: 100, now: () => clock.t });

// This stands in for the network call the cache is there to protect.
let fetches = 0;
function loadUser(id) {
  const key = "user:" + id;

  const cached = cache.get(key);
  if (cached !== null) return cached;   // served from cache, no request made

  fetches++;                            // only ever runs on a miss
  const fresh = { id, name: "User " + id };
  cache.set(key, fresh);
  return fresh;
}

console.log(loadUser(7).name);   // t=0   -> miss, so it "fetches"

clock.t = 50;
console.log(loadUser(7).name);   // t=50  -> still inside the 100ms window: hit

clock.t = 150;
console.log(loadUser(7).name);   // t=150 -> past expiry: evicted and refetched

console.log("network calls made:", fetches);   // 2, not 3
console.log("entries held:", cache.size());

// βœ… Expected output:
// MISS user:7 (never cached)
// cached user:7 until t=100
// User 7
// HIT user:7 at t=50
// User 7
// EXPIRED user:7 at t=150
// cached user:7 until t=250
// User 7
// network calls made: 2
// entries held: 1

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.

function normalizeUser(raw) {
  return {
    id: raw.id,
    name: raw.name,
    email: raw.email,
    // Careful: writing "raw.status === 'active' || true" here would be ALWAYS
    // true β€” false || true is true. The comparison alone is what you want.
    isActive: raw.status === "active",
    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:", user.company);
});

🎯 Your Turn

Write the normaliser yourself. Two of the blanks are string clean-up, one is the boolean people get wrong, and one is the fallback for a field the API simply did not send. Fill in each ___ and compare with the expected output at the bottom of the file.

// 🎯 YOUR TURN β€” write the normaliser
// An API layer exists to hand the rest of your app ONE predictable shape, no
// matter how messy the payload is. Replace each ___ then press Run.

const rawFromApi = {
  id: 42,
  full_name: "  Ada Lovelace  ",
  email_address: "[email protected]",
  status: "active",
  company: { name: "Analytical Engines Ltd" },
  last_login: null
};

const rawIncomplete = {
  id: 43,
  full_name: "Bob",
  email_address: "[email protected]",
  status: "suspended"
  // no company, no last_login β€” real payloads are like this
};

function normalizeUser(raw) {
  return {
    id: raw.id,

    // Strip the stray whitespace the API sends
    name: raw.full_name.___(),        // πŸ‘‰ which String method trims both ends?

    // Store every email in one consistent case
    email: raw.email_address.___(),   // πŸ‘‰ which String method lowercases a string?

    // A real boolean, not a string. (Adding "|| true" here would make it
    // always true β€” that is a genuine bug people ship.)
    isActive: raw.status === ___,     // πŸ‘‰ which status counts as active? use quotes

    // "company" may be missing entirely. ?. gives undefined instead of
    // throwing, and ?? then supplies the fallback.
    company: raw.company?.name ?? ___,   // πŸ‘‰ the fallback text, in quotes: Unknown

    lastLogin: raw.last_login ?? "never"
  };
}

const a = normalizeUser(rawFromApi);
const b = normalizeUser(rawIncomplete);

console.log(a.name + " | " + a.email + " | active=" + a.isActive + " | " + a.company);
console.log(b.name + " | " + b.email + " | active=" + b.isActive + " | " + b.company);
console.log("lastLogin:", a.lastLogin, "/", b.lastLogin);

// βœ… Expected output:
// Ada Lovelace | [email protected] | active=true | Analytical Engines Ltd
// Bob | [email protected] | active=false | Unknown
// lastLogin: never / never

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.

// 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) - all run at once
async function loadParallel() {
  console.time("Parallel");
  const [users, posts, comments] = await Promise.all([
    fetch("https://jsonplaceholder.typicode.com/users").then(r => r.json()),
    fetch("https://jsonplaceholder.typicode.com/posts").then(r => r.json()),
    fetch("https://jsonplaceholder.typicode.com/comments").then(r => r.json())
  ]);
  console.timeEnd("Parallel");
  return { users, posts, comments };
}

// Compare the two approaches
loadSequential().then(() => loadParallel());

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.

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 testErrorHandler() {
  try {
    const response = await fetch("https://jsonplaceholder.typicode.com/invalid-endpoint");
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }
  } catch (e) {
    const message = handleApiError(e);
    console.log("User-friendly error:", message);
  }
}

testErrorHandler();

🎯 Mini-Challenge: One Error Shape for the Whole App

A handler that returns a bare string is a start, but the UI usually needs more than wording: it needs to know whether a retry button makes sense, and it needs a stable value it can branch on. Write a classifier that returns all three every time.

Nothing is filled in. You get the rules, in the order they must be checked, and a fixed test drive β€” match the expected output and your classifier is correct.

// 🎯 MINI-CHALLENGE: one error shape for the whole app
//
// Write classifyError(error) from scratch. Only the brief is here.
//
// It receives an object shaped { name, status }, where "name" is the
// JavaScript error name and "status" is the HTTP status β€” or 0 when the
// request never reached the server at all. It returns an object with exactly
// three fields: kind, retryable and message.
//
// Apply the rules IN THIS ORDER (the order matters β€” the first case has
// status 0 as well, and must not be reported as offline):
//
//   name is "AbortError"  -> timeout   retryable true   "Request timed out. Try again."
//   status is 0           -> offline   retryable true   "You appear to be offline."
//   status 401 or 403     -> auth      retryable false  "Please sign in again."
//   status 404            -> notFound  retryable false  "We could not find that."
//   status 500 to 599     -> server    retryable true   "Something went wrong on our side."
//   anything else         -> unknown   retryable false  "Something unexpected happened."

// your code here


// --- Do not change the code below: this is the test drive ---
const cases = [
  { name: "AbortError", status: 0 },
  { name: "TypeError",  status: 0 },
  { name: "HttpError",  status: 401 },
  { name: "HttpError",  status: 403 },
  { name: "HttpError",  status: 404 },
  { name: "HttpError",  status: 503 },
  { name: "HttpError",  status: 418 }
];

cases.forEach(c => {
  const r = classifyError(c);
  console.log(c.status + " -> " + r.kind + " | retry=" + r.retryable + " | " + r.message);
});

// βœ… Expected output:
// 0 -> timeout | retry=true | Request timed out. Try again.
// 0 -> offline | retry=true | You appear to be offline.
// 401 -> auth | retry=false | Please sign in again.
// 403 -> auth | retry=false | Please sign in again.
// 404 -> notFound | retry=false | We could not find that.
// 503 -> server | retry=true | Something went wrong on our side.
// 418 -> unknown | retry=false | Something unexpected happened.

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.

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(`POST failed: ${response.status}`);
    return response.json();
  }
}

const api = new Api("https://jsonplaceholder.typicode.com");

// Clean, semantic API calls
api.get("/users/1").then(user => console.log("User:", user.name));
api.post("/posts", { title: "Hello", body: "World", userId: 1 })
   .then(post => console.log("Created post:", post));

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.

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("https://jsonplaceholder.typicode.com/posts", 3)
  .then(posts => console.log("Total posts loaded:", posts.length));

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.

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}"`);
  return entry.value;
}

async function loadStats() {
  const cached = cacheGet("stats");
  if (cached) return cached;

  const response = await fetch("https://jsonplaceholder.typicode.com/posts");
  const stats = await response.json();
  cacheSet("stats", stats, 5000); // 5 second TTL for demo
  return stats;
}

// First call - cache miss
loadStats().then(s => console.log("Posts:", s.length));

// Second call after 2s - cache hit
setTimeout(() => loadStats().then(s => console.log("Posts:", s.length)), 2000);

// Third call after 6s - cache expired
setTimeout(() => loadStats().then(s => console.log("Posts:", s.length)), 6000);

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.

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 three calls share ONE network request
Promise.all([
  dedupedFetch(url),
  dedupedFetch(url),
  dedupedFetch(url)
]).then(([a, b, c]) => {
  console.log("All 3 results identical:", a === b && b === c);
  console.log("Users loaded:", a.length);
});

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.

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 < retries; attempt++) {
    try {
      const res = await fetch(url, {
        method,
        body: body ? JSON.stringify(body) : null,
        headers: {
          "Content-Type": "application/json",
          ...headers
        },
        signal: controller.signal
      });

      clearTimeout(timer);

      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const json = await res.json();
      const transformed = transform(json);

      if (cache) {
        sessionStorage.setItem(url, JSON.stringify(transformed));
      }

      return transformed;
    } catch (err) {
      console.log(`Attempt ${attempt + 1} failed`);
      if (attempt === retries - 1) throw err;
      await new Promise(r => setTimeout(r, 300 * (attempt + 1)));
    }
  }
}

// Usage with all features
universalAPI("https://jsonplaceholder.typicode.com/posts", {
  cache: true,
  timeout: 5000,
  transform: data => data.slice(0, 5) // Only first 5 posts
}).then(posts => {
  console.log("Loaded posts:", posts.map(p => p.title));
});

Security Considerations

⚠️ Security Warning: Browser storage is not secure for sensitive data.

What You Learned

Core Patterns

Advanced Techniques

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.

Practice quiz

What is the main purpose of building an API layer, per the lesson?

  • To centralize fetch logic into a reusable system instead of scattering repetitive calls
  • To make the UI render faster
  • To replace the need for a backend
  • To avoid using async/await

Answer: To centralize fetch logic into a reusable system instead of scattering repetitive calls. An API layer centralizes everything into a reusable system, reducing duplicated code and managing headers, errors, and caching in one place.

Which browser API is used to add a timeout that cancels a slow request?

  • setTimeout alone
  • AbortController
  • Promise.race only
  • XMLHttpRequest

Answer: AbortController. AbortController lets you cancel requests; you abort the controller after a timeout and pass its signal to fetch.

In the timeout example, what error name indicates the request was aborted?

  • TimeoutError
  • NetworkError
  • AbortError
  • FetchError

Answer: AbortError. When a request is aborted, the error has name 'AbortError', which the lesson checks to log 'Request timed out!'.

How does the retry pattern's exponential backoff change the delay between attempts?

  • The delay stays constant
  • The delay doubles after each failed attempt
  • The delay is halved each time
  • The delay is random with no pattern

Answer: The delay doubles after each failed attempt. The retry helper passes delay * 2 on each recursive retry, so the delay doubles (300ms then 600ms then 1200ms).

How does the token-aware API client attach authentication to requests?

  • By adding a query string ?token=
  • By setting an Authorization: Bearer <token> header
  • By putting the token in the URL path
  • By using a cookie automatically

Answer: By setting an Authorization: Bearer <token> header. The client reads the token from localStorage and sets the Authorization header to `Bearer ${token}` on every request.

Which method runs multiple fetches in parallel rather than sequentially?

  • Promise.all
  • await in a for loop
  • Promise.resolve
  • Array.forEach with await

Answer: Promise.all. Promise.all([...]) runs all requests at once (parallel), which is much faster than awaiting each sequentially.

What problem does the request deduplication pattern solve?

  • Slow JSON parsing
  • Multiple components requesting the same URL sending duplicate network calls
  • Expired auth tokens
  • CORS errors

Answer: Multiple components requesting the same URL sending duplicate network calls. Deduplication stores the pending promise in a Map keyed by URL so concurrent callers share ONE network request.

In the TTL cache, what determines whether a cached entry is still valid?

  • Whether the Map has any entries
  • Whether Date.now() is past the entry's expiry time
  • Whether the user is logged in
  • The size of the cached value

Answer: Whether Date.now() is past the entry's expiry time. cacheGet deletes and returns null when Date.now() > entry.expiry; otherwise it returns the cached value (a cache hit).

Why does data normalization happen inside the API client?

  • To avoid refactoring components every time the API shape changes
  • To make requests faster
  • To bypass authentication
  • To reduce the number of requests

Answer: To avoid refactoring components every time the API shape changes. Normalizing data in the client (e.g. normalizeUser) reshapes responses so UI components don't need to change when the API structure differs.

Per the Security Considerations, what should you store in localStorage?

  • User passwords
  • Only tokens, never sensitive user data
  • Full credit card numbers
  • Hashed passwords

Answer: Only tokens, never sensitive user data. The lesson warns browser storage isn't secure: limit localStorage to tokens only and never store passwords or sensitive user data.

Continue this course