Implement It from Scratch: 5 Front-End Networking and Async Utilities Every Developer Should Know
In front-end technical screenings and machine-coding interviews, live coding rounds frequently test your ability to implement production-grade asynchronous utilities from scratch. Rather than relying on popular npm packages, interviewers expect you to handle concurrency, network resilience, cancellation, and timing mechanics using standard JavaScript and browser APIs.
Interviewers evaluate:
- Network Resilience & Backoff: Understanding transient network errors, exponential backoff with full jitter, and selective status code retries.
- Concurrency Control: Managing asynchronous queues, executing task pools with bounded parallelism, and preventing resource exhaustion.
- Request Deduplication & Caching: Eliminating concurrent duplicate requests (the "thundering herd" problem) by memoizing in-flight promises.
- Polling & Completion Criteria: Implementing non-blocking polling loops with timeout protection and adaptive interval pacing.
- Request Batching (DataLoader Pattern): Grouping discrete read operations initiated in the same event-loop microtask into bulk network dispatches.
This guide walks through 5 high-frequency front-end networking and asynchronous coding problems for developers with 0–5 years of experience.
Question Index
| # | Utility | Core Focus | Difficulty |
|---|---|---|---|
| 1 | fetchWithRetry | Exponential backoff, jitter, HTTP status filtering, AbortSignal | Junior-Mid |
| 2 | pLimit / Concurrency Task Pool | Fixed concurrency queue, Promise resolution chaining | Mid |
| 3 | dedupeRequest / In-Flight Memoizer | Promise reuse, concurrent cache clearing, error handling | Mid |
| 4 | pollUntil | Periodic asynchronous polling, predicate evaluation, timeouts | Mid |
| 5 | createBatcher / Request Batcher | Microtask scheduling (queueMicrotask), bulk request consolidation | Mid-Senior |
1. Implement fetchWithRetry with Exponential Backoff and Jitter
Requirements
Implement a wrapper around fetch that automatically retries failed HTTP requests:
- Support configurable
maxRetries(default: 3) andinitialDelayMs(default: 500ms). - Use exponential backoff:
delay = initialDelayMs * (2 ** attempt). - Add full jitter (randomizing the delay between 0 and
delay) to prevent thundering herd spikes on downstream services. - Only retry transient network errors and server errors (
status >= 500orstatus === 429); do not retry client errors like400,401, or404. - Respect user-supplied
AbortSignalfor manual cancellation, immediately aborting without waiting for pending retry timeouts.
Clarifying Questions to Ask the Interviewer
- "Should non-2xx HTTP status codes be treated as errors, or only network exceptions (DNS failures, dropped sockets)?" (Usually: throw on network exceptions and
status >= 500or429). - "How should manual user abortion be handled?" (If the caller's
AbortSignalfires, immediately reject withAbortErrorand clear any pendingsetTimeout). - "Do you want exponential backoff, linear backoff, or backoff with jitter?" (Explain why exponential backoff with jitter is production standard).
Approach
- Run an async loop from attempt 0 up to
maxRetries. - Wrap the
fetchcall in atry...catchblock. - Check if the response failed (
!response.ok). Ifresponse.status === 429(Rate Limited) or>= 500(Server Error), treat it as retryable. If it's a 4xx error (e.g., 404 Not Found), return the response immediately without retrying. - If a retryable failure occurs and attempts remain, calculate the exponential backoff with random jitter.
- Create a cancellable sleep promise tied to
signal.addEventListener('abort')to ensure immediate termination if aborted during sleep.
Implementation
interface RetryOptions {
maxRetries?: number;
initialDelayMs?: number;
backoffFactor?: number;
retryOnStatus?: (status: number) => boolean;
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
return reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'));
};
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function fetchWithRetry(
input: RequestInfo | URL,
init?: RequestInit,
options: RetryOptions = {}
): Promise<Response> {
const {
maxRetries = 3,
initialDelayMs = 500,
backoffFactor = 2,
retryOnStatus = (status) => status === 429 || status >= 500,
} = options;
let attempt = 0;
while (true) {
try {
const response = await fetch(input, init);
// If successful or non-retryable status (like 401, 404), return immediately
if (response.ok || !retryOnStatus(response.status)) {
return response;
}
if (attempt >= maxRetries) {
return response;
}
} catch (err: unknown) {
// If manually aborted by caller, rethrow immediately
if (init?.signal?.aborted) {
throw err;
}
if (attempt >= maxRetries) {
throw err;
}
}
attempt++;
// Exponential backoff with full jitter: random value between 0 and calculated delay
const maxDelay = initialDelayMs * Math.pow(backoffFactor, attempt - 1);
const jitterDelay = Math.random() * maxDelay;
await sleep(jitterDelay, init?.signal);
}
}
Edge Cases Handled
- Caller Aborts Mid-Sleep: Listening for
signal.abortedclears the pending timer and rejects instantly instead of sleeping out the duration. - Client 4xx Errors: Non-retryable client errors (like 401 Unauthorized or 404 Not Found) are not retried, avoiding unnecessary network traffic.
- Max Retries Exceeded: Returns the final failed response (or rethrows network error) once
maxRetriesis exhausted.
Complexity
- Time Complexity:
O(maxRetries)network requests withO(2^maxRetries)aggregate backoff sleep delay. - Space Complexity:
O(1)auxiliary memory beyond standard Request and Response references.
Usage Example
const controller = new AbortController();
fetchWithRetry('https://api.example.com/data', {
signal: controller.signal
}, {
maxRetries: 3,
initialDelayMs: 400
})
.then(res => res.json())
.then(data => console.log('Loaded:', data))
.catch(err => console.error('Fetch failed after retries:', err));
Likely Follow-ups
- "How would you inspect the
Retry-AfterHTTP header from a 429 response?" (Checkresponse.headers.get('Retry-After'), parse integer seconds or HTTP date, and overridejitterDelaywith that duration). - "Why use full jitter over decorrelated jitter?" (Full jitter spreads concurrent retry stamps evenly across the interval [0, delay], eliminating thundering herd bursts).
2. Implement a Concurrency Task Pool (pLimit)
Requirements
Implement a concurrency limiter that constrains parallel execution of asynchronous functions:
- Accept a
concurrencyinteger limit (e.g.,concurrency = 3). - Return a function
limit(fn)that enqueues taskfnand returns a Promise resolving tofn()'s result. - Guarantee that at most
concurrencytasks execute concurrently. - As soon as any running task settles (fulfills or rejects), immediately start the next queued task.
- Preserve error propagation so task rejections reject the returned promise without stalling the queue.
Clarifying Questions to Ask the Interviewer
- "Can tasks be added dynamically at different times?" (Yes, callers should be able to invoke
limit(fn)anytime). - "What if a task throws synchronously rather than returning a rejected Promise?" (Wrap in
Promise.resolve().then(fn)to catch synchronous throws). - "Should the queue support clearing or cancellation?" (Explain how tasks can be rejected if the queue is aborted).
Approach
- Maintain an integer counter
activeCounttracking currently running tasks. - Maintain a FIFO queue of pending task generator functions.
- When
limit(fn)is called, return a newPromise((resolve, reject) => { ... }). - Wrap
fnin an execution runner: when active slots are free (activeCount < concurrency), run the task immediately; otherwise, push the runner into the queue. - In the runner's
finallyhandler, decrementactiveCountand dequeue the next runner if one exists.
Implementation
type Task<T> = () => Promise<T> | T;
export function pLimit(concurrency: number) {
if (!Number.isInteger(concurrency) || concurrency < 1) {
throw new TypeError('Concurrency must be a positive integer');
}
const queue: Array<() => void> = [];
let activeCount = 0;
const next = () => {
activeCount--;
if (queue.length > 0) {
const runner = queue.shift()!;
runner();
}
};
const run = async <T>(task: Task<T>, resolve: (val: T) => void, reject: (err: unknown) => void) => {
activeCount++;
try {
const result = await task();
resolve(result);
} catch (err) {
reject(err);
} finally {
next();
}
};
return function limit<T>(task: Task<T>): Promise<T> {
return new Promise<T>((resolve, reject) => {
const runner = () => run(task, resolve, reject);
if (activeCount < concurrency) {
runner();
} else {
queue.push(runner);
}
});
};
}
Edge Cases Handled
- Synchronous Throws: Wrapped in
async run(...), catching synchronous errors intaskand rejecting properly. - Task Rejections Do Not Stall Queue: The
finallyblock always executesnext(), ensuring queued tasks proceed even if earlier tasks reject. - Invalid Concurrency: Rejects zero, negative, or non-integer limits upfront.
Complexity
- Time Complexity:
O(1)enqueue and dequeue operations. Total runtime isO(total task execution time / concurrency). - Space Complexity:
O(N)queue storage whereNis the number of queued pending tasks.
Usage Example
const limit = pLimit(2); // Max 2 parallel requests
const urls = ['/item/1', '/item/2', '/item/3', '/item/4'];
const fetchItem = (url: string) => limit(() => fetch(url).then(r => r.json()));
Promise.all(urls.map(fetchItem)).then(results => {
console.log('All 4 items fetched with at most 2 active requests at a time');
});
Likely Follow-ups
- "How would you expose pending and active task counts?" (Add getters
limit.activeCountandlimit.pendingCountreadingactiveCountandqueue.length). - "How does this compare to
Promise.all?" (Promise.allexecutes all promises immediately with unbounded concurrency, which can trigger browser socket limits or 429 rate limits).
3. Implement In-Flight Request Deduplication (dedupeRequest)
Requirements
Implement a utility that deduplicates simultaneous in-flight network requests:
- Accept a key generator and an asynchronous function
fetcher(key). - When multiple callers request the same key concurrently, dispatch only one underlying network request.
- Return the same shared Promise to all concurrent callers.
- Once the request settles (fulfills or rejects), immediately purge it from the in-flight cache so subsequent calls trigger a fresh request.
- If the request fails, all waiting callers reject with that error, and the key is still evicted.
Clarifying Questions to Ask the Interviewer
- "Should fulfilled results be cached for future calls, or only deduplicated while pending?" (Only while pending; once complete, future calls fetch fresh data. Differentiate in-flight deduplication from full HTTP caching).
- "How should cache keys be constructed?" (Accept an optional serializer or string key).
Approach
- Maintain an internal
Map<string, Promise<any>>storing active in-flight promises. - When called with a key, check
map.has(key). If present, return the existing Promise. - If absent, execute
fetcher(key)and store the resulting Promise in the map. - Attach a
.finally(() => map.delete(key))to the stored Promise to guarantee cache clearance upon settlement. - Return the Promise to all callers.
Implementation
type AsyncFn<Args extends any[], Return> = (...args: Args) => Promise<Return>;
export function dedupeRequest<Args extends any[], Return>(
fn: AsyncFn<Args, Return>,
keyResolver: (...args: Args) => string = (...args) => JSON.stringify(args)
): AsyncFn<Args, Return> {
const inFlightMap = new Map<string, Promise<Return>>();
return function (...args: Args): Promise<Return> {
const key = keyResolver(...args);
const existingPromise = inFlightMap.get(key);
if (existingPromise) {
return existingPromise;
}
// Execute the fetcher and store the active promise
const promise = fn(...args).finally(() => {
// Crucial: remove from in-flight cache as soon as it settles
inFlightMap.delete(key);
});
inFlightMap.set(key, promise);
return promise;
};
}
Edge Cases Handled
- Promise Rejection: The
.finally()callback executes on both fulfillment and rejection, ensuring failed requests never leave poisoned permanent entries in the cache. - Thundering Herd: 100 components requesting
/api/user/meon mount trigger exactly 1 network request and all 100 receive the exact same response. - Subsequent Requests: Calling the function after settlement initiates a new network request, maintaining fresh data.
Complexity
- Time Complexity:
O(1)key resolution and Map lookup. - Space Complexity:
O(K)whereKis the number of distinct concurrent in-flight requests.
Usage Example
const fetchUserData = dedupeRequest(async (userId: string) => {
const res = await fetch(`/api/users/${userId}`);
return res.json();
});
// Component A and Component B mount simultaneously:
fetchUserData('user-123'); // Triggers actual HTTP request
fetchUserData('user-123'); // Reuses in-flight promise, zero extra network traffic
Likely Follow-ups
- "How would you extend this to support Time-To-Live (TTL) caching?" (Store
{ promise, timestamp }and only evict afterDate.now() > timestamp + ttlMs). - "What happens if caller A aborts using an AbortSignal?" (If multiple callers share a Promise, individual caller cancellation must be decoupled from the shared fetch signal).
4. Implement a Polling Utility (pollUntil) with Timeout
Requirements
Implement a polling helper that repeatedly executes an async function until a predicate returns true:
- Accept an async task function
fn(), avalidate(data)predicate, and options:intervalMs,timeoutMs, and optionalmaxAttempts. - Poll repeatedly: execute
fn(), test result withvalidate(data). If valid, resolve with data. - If invalid and time remains, wait
intervalMsbefore the next attempt. - If
timeoutMsexpires ormaxAttemptsis reached, reject with a descriptiveTimeoutError. - Support cancellation via
AbortSignal.
Clarifying Questions to Ask the Interviewer
- "Should interval timing start after the previous request resolves, or on a fixed clock?" (After the request resolves, preventing overlapping requests if an attempt takes longer than
intervalMs). - "How should errors during polling be handled?" (Allow configurable error handling: abort immediately or count as a failed poll attempt).
Approach
- Record
startTime = Date.now(). - Loop while elapsed time
< timeoutMsandattempt < maxAttempts. - Check
signal.abortedon each iteration. - Execute
const result = await fn(). - If
validate(result)is truthy, returnresult. - Calculate remaining time; sleep for
min(intervalMs, remainingTime)before the next check. - Throw a
TimeoutErrorif timeout is exceeded without a valid result.
Implementation
interface PollOptions<T> {
intervalMs?: number;
timeoutMs?: number;
maxAttempts?: number;
signal?: AbortSignal;
}
export async function pollUntil<T>(
fn: () => Promise<T>,
validate: (data: T) => boolean,
options: PollOptions<T> = {}
): Promise<T> {
const {
intervalMs = 1000,
timeoutMs = 15000,
maxAttempts = Infinity,
signal,
} = options;
const startTime = Date.now();
let attempt = 0;
while (attempt < maxAttempts) {
if (signal?.aborted) {
throw signal.reason ?? new DOMException('Polling aborted', 'AbortError');
}
attempt++;
const result = await fn();
if (validate(result)) {
return result;
}
const elapsed = Date.now() - startTime;
const remaining = timeoutMs - elapsed;
if (remaining <= 0) {
break;
}
// Sleep for the interval or the remaining timeout duration, whichever is shorter
const waitTime = Math.min(intervalMs, remaining);
await new Promise((resolve, reject) => {
const timer = setTimeout(resolve, waitTime);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(signal.reason ?? new DOMException('Polling aborted', 'AbortError'));
}, { once: true });
});
}
throw new Error(`Polling timed out after ${Date.now() - startTime}ms (${attempt} attempts)`);
}
Edge Cases Handled
- Slow Network Responses: Because polling awaits the resolution before sleeping, slow requests never stack or overlap.
- Timeout Mid-Request: Re-checks remaining budget after request completion.
- Caller Abort: Interrupts sleep or loop immediately when
signalaborts.
Complexity
- Time Complexity: Dependent on time until validation succeeds, bounded by
min(timeoutMs, maxAttempts * (taskTime + intervalMs)). - Space Complexity:
O(1)memory overhead.
Usage Example
// Poll for export job status:
const completedJob = await pollUntil(
() => fetch('/api/jobs/export-123').then(r => r.json()),
(job) => job.status === 'COMPLETED',
{ intervalMs: 2000, timeoutMs: 30000 }
);
console.log('File ready at:', completedJob.downloadUrl);
Likely Follow-ups
- "How would you add exponential backoff to polling?" (Multiply
intervalMsby a factor (e.g. 1.5) on each attempt, capped at amaxIntervalMs). - "What if the status is 'FAILED'?" (Pass an optional
isTerminal(data)predicate that rejects immediately instead of waiting for timeout).
5. Implement a Request Batcher (createBatcher / DataLoader Pattern)
Requirements
Implement a batching utility that consolidates discrete asynchronous calls within the same tick of the event loop into a single bulk request:
- Accept a
batchFn: (keys: K[]) => Promise<Map<K, V> | Record<K, V>>. - Return a getter function
load(key: K): Promise<V>. - When
load(key)is invoked multiple times synchronously in the same execution turn, collect all keys. - Schedule a single execution of
batchFn(keys)in a microtask (queueMicrotask). - Resolve each caller's promise with their respective value from the bulk result, or reject all if the batch fails.
Clarifying Questions to Ask the Interviewer
- "How should scheduling be coordinated?" (Use
queueMicrotaskto dispatch after all synchronous code in the current task completes). - "What if a key is requested multiple times in the same batch?" (Deduplicate keys before passing to
batchFn, and resolve all callers with that key). - "How should missing keys in the batch result be handled?" (Reject the individual caller's promise with a
NotFoundError).
Approach
- Maintain a queue of pending requests:
{ key, resolve, reject }. - When
load(key)is called, push the entry into the queue. - If no batch dispatch is scheduled, set a flag and call
queueMicrotask(flush). - Inside
flush, drain the queue, extract unique keys, and invokebatchFn(uniqueKeys). - Map results back to the individual callers and resolve/reject accordingly.
Implementation
type BatchFn<K, V> = (keys: K[]) => Promise<Map<K, V>>;
export function createBatcher<K, V>(batchFn: BatchFn<K, V>) {
type QueueItem = {
key: K;
resolve: (val: V) => void;
reject: (err: unknown) => void;
};
let queue: QueueItem[] = [];
let isScheduled = false;
const flush = async () => {
const currentQueue = queue;
queue = [];
isScheduled = false;
// Collect unique keys
const uniqueKeys = Array.from(new Set(currentQueue.map((item) => item.key)));
try {
const resultMap = await batchFn(uniqueKeys);
for (const item of currentQueue) {
if (resultMap.has(item.key)) {
item.resolve(resultMap.get(item.key)!);
} else {
item.reject(new Error(`Key not found in batch result: ${String(item.key)}`));
}
}
} catch (batchError) {
for (const item of currentQueue) {
item.reject(batchError);
}
}
};
return function load(key: K): Promise<V> {
return new Promise<V>((resolve, reject) => {
queue.push({ key, resolve, reject });
if (!isScheduled) {
isScheduled = true;
// Schedule dispatch at the end of the current microtask queue
queueMicrotask(flush);
}
});
};
}
Edge Cases Handled
- Microtask Timing: Using
queueMicrotaskguarantees that all synchronous calls in the current tick (like rendering a list of child components) batch together before network dispatch. - Batch Failure: If
batchFnrejects, all waiting promises in the batch are rejected properly. - Duplicate Keys in Single Batch: Keys are deduplicated with
new Set, preventing redundant queries to the backend.
Complexity
- Time Complexity:
O(N)queue dispatch time whereNis the number of queued items, savingN - 1HTTP network round-trips. - Space Complexity:
O(N)memory for queued promises and result maps.
Usage Example
// Define bulk loader
const userLoader = createBatcher(async (userIds: string[]) => {
console.log('Sending single bulk HTTP request for:', userIds);
const res = await fetch(`/api/users?ids=${userIds.join(',')}`);
const users: Array<{ id: string; name: string }> = await res.json();
return new Map(users.map(u => [u.id, u]));
});
// Render 3 separate components in the same render cycle:
userLoader('1').then(u => console.log('Component 1 got:', u.name));
userLoader('2').then(u => console.log('Component 2 got:', u.name));
userLoader('1').then(u => console.log('Component 3 got:', u.name));
// Logs: "Sending single bulk HTTP request for: ['1', '2']"
Likely Follow-ups
- "Why use
queueMicrotaskoversetTimeout(..., 0)?" (queueMicrotaskexecutes at the end of the current JavaScript turn before rendering or macrotasks, minimizing latency without adding an artificial 4ms timer clamp). - "How does this solve the N+1 problem in front-end component trees?" (Child components independently request their own data while the batcher coalesces them into one HTTP request).
Preparation Checklist
To excel in front-end asynchronous and networking live-coding rounds, review this checklist:
- Master AbortController: Practice wiring
signal.addEventListener('abort')inside customsleepandfetchhelpers. - Memorize exponential backoff with jitter: Understand why full jitter
Math.random() * maxDelayprevents synchronized retry spikes. - Manage task pools with FIFO queues: Practice implementing bounded concurrency with
activeCountand a queue. - Differentiate caching from in-flight deduplication: In-flight deduplication purges promises immediately on
.finally(); TTL caching preserves them. - Leverage microtask scheduling: Use
queueMicrotaskto coalesce synchronous callers into batch requests (DataLoader pattern). - Avoid non-cancellable timers: Always clean up
setTimeoutlisteners when promises resolve or abort to avoid memory leaks. - Handle rejections gracefully: Ensure helper functions reject cleanly without leaving pending promises hanging indefinitely.
Conclusion
Live coding rounds for networking utilities test far more than syntax—they expose how deeply you understand asynchronous control flow, resource limits, and real-world network resilience. By mastering exponential backoff, concurrency queues, request deduplication, polling, and microtask batching, you demonstrate senior-level engineering discipline that translates directly to robust production web applications.