Free lesson · JavaScript Basic to Advance
The Data Layer: Fetching, Caching, Promises
The First Real Code
We start with the data layer. This is where the most course concepts cluster: closures for the cache, classes for the API wrapper, Promises and async/await throughout, error handling, cancellation, and a few of the smaller modern-syntax patterns.
// What we build in this Topic:
//
// data/cache.js — a closure-based cache with TTL and size limits
// data/api.js — an API wrapper class with fetch, retry, and cancellation
//
// Concepts demonstrated:
// - Closures (Module 6) for the cache's private state
// - Promises and async/await (Module 9) for the fetch logic
// - AbortController (Topic 9.7, 12.5) for cancellation
// - Error handling patterns (Topic 9.7)
// - Class structure (Module 8)
By the end of this Topic, we'll have a working data layer that can fetch from any API, cache responses, retry on failure, and cancel in-flight requests. Each piece connects to specific course material — I'll point to the source Topic as we go.
The Cache: Closures in Action
We covered closures extensively in Module 6. Topic 6.4 specifically showed closures for private state. Our cache is exactly that pattern, applied to a real problem.
// data/cache.js
export function createCache({ maxSize = 50, ttl = 60_000 } = {}) {
const entries = new Map(); // private state via closure
function isExpired(entry) {
return Date.now() - entry.timestamp > ttl;
}
function evictOldest() {
const firstKey = entries.keys().next().value;
if (firstKey !== undefined) entries.delete(firstKey);
}
return {
get(key) {
const entry = entries.get(key);
if (!entry) return undefined;
if (isExpired(entry)) {
entries.delete(key);
return undefined;
}
// Refresh LRU position by re-inserting
entries.delete(key);
entries.set(key, entry);
return entry.value;
},
set(key, value) {
if (entries.has(key)) entries.delete(key);
entries.set(key, { value, timestamp: Date.now() });
if (entries.size > maxSize) evictOldest();
},
clear() {
entries.clear();
},
get size() {
return entries.size;
}
};
}
// Demo it
const cache = createCache({ maxSize: 3, ttl: 5_000 });
cache.set("a", { name: "alpha" });
cache.set("b", { name: "beta" });
cache.set("c", { name: "gamma" });
console.log(cache.get("a")); // {name: "alpha"}
console.log("Size:", cache.size); // 3
cache.set("d", { name: "delta" }); // evicts "b" (oldest after recent access of "a")
console.log("Has b?", cache.get("b")); // undefined
console.log("Has a?", cache.get("a")); // still there
Walk through what's happening:
The closure. entries is a Map declared inside createCache. The returned object has methods that close over entries. From outside, nothing can directly access entries — only the methods can. This is exactly the pattern from Topic 6.4.
Why a closure, not a class? Either would work. Closure for cache makes sense because the cache has no inheritance and no this-binding complications. The factory function returns an object with methods, and the closure handles the private state. Topic 6.4 vs Topic 8.5 covered the trade-off — both produce real privacy, but closures are simpler for cases like this.
LRU behavior via Map insertion order. Topic 10.4 covered Maps' guaranteed insertion order. We exploit that: when we access an entry, we re-insert it (delete then set), which moves it to the most-recently-inserted position. When we evict, we always remove the first-inserted (oldest) entry.
TTL via timestamps. Each entry stores its insertion time. On get, we check if it's expired and delete it. This is a simple but effective TTL implementation.
Default parameters with destructuring. The function signature ({ maxSize = 50, ttl = 60_000 } = {}) is from Topic 10.1 and Topic 5.4. Destructure the options object, provide defaults, and default the whole argument to {} so calling createCache() with no arguments doesn't throw.
The API Wrapper: Promises and Cancellation
Now the API class. This handles fetching, error mapping, retry, and cancellation.
// data/api.js
export class Api {
constructor({ baseUrl, cache, maxRetries = 2 }) {
this.baseUrl = baseUrl;
this.cache = cache;
this.maxRetries = maxRetries;
this.activeController = null;
}
cancel() {
if (this.activeController) {
this.activeController.abort();
this.activeController = null;
}
}
buildCacheKey(query) {
// For a real app, you'd want a more careful cache key
return JSON.stringify(query);
}
async fetch(query) {
const cacheKey = this.buildCacheKey(query);
// 1. Check cache first
const cached = this.cache?.get(cacheKey);
if (cached) return cached;
// 2. Cancel any in-flight request
this.cancel();
this.activeController = new AbortController();
// 3. Fetch with retry
const result = await this.fetchWithRetry(query, this.activeController.signal);
// 4. Cache the result
this.cache?.set(cacheKey, result);
return result;
}
async fetchWithRetry(query, signal) {
let lastError;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await this.fetchOnce(query, signal);
} catch (err) {
if (err.name === "AbortError") throw err; // don't retry cancellations
lastError = err;
if (attempt < this.maxRetries) {
await new Promise(resolve => setTimeout(resolve, 200 * Math.pow(2, attempt)));
}
}
}
throw new Error(`Failed after ${this.maxRetries + 1} attempts: ${lastError.message}`);
}
async fetchOnce(query, signal) {
const url = `${this.baseUrl}/items?${new URLSearchParams(query).toString()}`;
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
}
}
// Demo with mock fetch
globalThis.fetch = async (url, options) => {
await new Promise(r => setTimeout(r, 50));
return {
ok: true,
status: 200,
statusText: "OK",
json: async () => [{ id: 1, name: "Demo product" }]
};
};
const cache = { get: () => undefined, set: () => {} };
const api = new Api({ baseUrl: "/api", cache });
api.fetch({ q: "test" }).then(r => console.log("Got:", r));
Walk through the key parts:
The Api class. Topic 8.2 covered class syntax. The constructor takes config options and initializes instance state. Methods are defined on the prototype (Topic 7.5, 8.2) so they're shared across instances.
AbortController for cancellation. Topic 9.7 and Topic 12.5 covered this. Each fetch creates a controller. Starting a new fetch cancels any previous one by calling abort() on the old controller. This pattern is essential for search-as-you-type and similar UI patterns.
fetchWithRetry — the retry pattern. Topic 9.7 covered this. A loop that tries the fetch, catches errors, waits with exponential backoff, and retries up to maxRetries times. Two refinements: we don't retry AbortError (cancellation is intentional, not a failure), and we use exponential backoff to avoid hammering a failing server.
response.ok check. Topic 12.5's most important detail: fetch does NOT throw on HTTP error status codes. A 404 still resolves the Promise normally. We have to check response.ok manually and throw if false. This is where most "fetch is broken" bugs come from.
Optional chaining for the cache. this.cache?.get(...) is from Topic 10.3. If this.cache is undefined (we passed no cache), the call returns undefined instead of throwing. This makes the cache parameter genuinely optional.
How These Two Connect
main.js (from Topic 15.2) will wire them together:
import { createCache } from "./data/cache.js";
import { Api } from "./data/api.js";
const cache = createCache({ maxSize: 50, ttl: 60_000 });
const api = new Api({ baseUrl: "/api", cache });
// Now any code that uses 'api' gets caching for free
api.fetch({ query: "shoes", sort: "price" })
.then(items => console.log(items));
The composition is the senior pattern. The Api class doesn't know about cache implementation details — it just calls cache.get() and cache.set(). The cache doesn't know about HTTP, errors, or retries. Each piece has its job and they connect through their interfaces.
Compare to an alternative design where caching is built into the Api class. It works, but it's less flexible — you can't easily swap the cache for a different one, can't test the API logic without the cache, can't reuse the cache for non-API data. The composition we used is more work upfront and pays off as the codebase grows.
graph LR
A[main.js<br/>creates and wires] --> B[Api instance]
A --> C[Cache instance]
B -->|uses| C
B -->|calls| D[fetch / API]
C -->|stores| E[Cached responses]
style B fill:#7c3aed,color:#fff
style C fill:#10b981,color:#fff
This diagram shows the data layer's structure. The main module creates both the Api instance and the cache instance, then injects the cache into the Api at construction. The Api uses the cache to avoid duplicate requests, calls the underlying fetch API for new requests, and stores results in the cache. The cache holds responses keyed by query parameters. Neither component knows about the other's implementation details — only about their interfaces.
Errors Across Layers
A pattern to internalize: errors at each layer get handled at the appropriate layer.
Inside fetchOnce — convert HTTP error responses into thrown Errors. This is where the response.ok check lives.
Inside fetchWithRetry — catch errors, decide whether to retry. Some errors retry (network failures), some don't (cancellations).
Inside fetch (the public method) — currently we let errors propagate up. Topic 15.5 will catch them at the state layer and display them in the UI.
The principle: handle errors where you have enough context to handle them meaningfully. Don't try to recover from everything in the deepest function. Don't swallow errors at the top either. Each layer makes the decision appropriate to its abstraction level.
Interactive animation: Failure class to state to sentence: each kind of failure becomes a state value and a message the user reads.
What We Built
Two modules, roughly 100 lines of real code. They demonstrate:
- Closures for private state (Module 6)
- Map's insertion-order behavior for LRU (Module 10)
- Class syntax with constructor and methods (Module 8)
- async/await for sequential async operations (Module 9)
- AbortController for cancellation (Modules 9 and 12)
- Exponential backoff retry (Module 9)
- The
response.okcheck (Module 12) - Optional chaining (Module 10)
- Destructuring with defaults (Module 10)
- Composition over coupling (Module 8)
These are real concepts working together. In an interview, when someone asks "build me a small data layer," this is approximately what they want to see. The code isn't long. The thinking behind each choice is.
Try It
Exercise: Extend the cache to add a
getStats()method that returns hit count, miss count, and current size. The pattern:
- Add two more private variables in the closure:
hitsandmisses- Increment them appropriately in
get- Expose
getStats()that returns an object with the countsThen run a small benchmark — populate the cache, do many
getcalls (some that hit, some that miss), and observe the stats.This is a real interview-style extension. "Now add observability" is a common follow-up to "build me a cache."