Simulate Fetch Latency: Fix API Mock Interception Bugs
The "My Mock Returns Instantly, So I'm Fine" Trap
Here's a scene that plays out in every frontend team: you build a feature against a local mock server or hardcoded JSON file. Everything feels snappy. Loading spinners flash for a split second—barely visible—and your UI renders perfectly. You ship it. Then production hits, and your users stare at a blank screen for 800 milliseconds while your skeleton loader never even gets a chance to appear. The component throws an error because a state transition you never tested locally actually happens when the network takes longer than 0 milliseconds.
The misconception is subtle but pervasive: developers assume that if the mock shape is correct, the mock is correct. But response shape is only half the contract. The other half—latency—determines whether your loading states, retry logic, race condition guards, and timeout fallbacks actually work. If you're building against api mock tools that return data instantly, you're testing your happy path and nothing else.
This guide is for frontend engineers who are wiring up features before the backend is ready—or who need to reproduce a production bug locally that only manifests under slow network conditions. We'll walk through the common mistakes teams make when simulating network request latency with fetch API interception, and how to fix each one.
Why Instant Mocks Quietly Break Your UI
Let's put a number on it. A typical REST API call in production takes anywhere from 120ms to 400ms on a fast connection. On a degraded mobile network, that same call can take 1.5 to 3 seconds. If your local mock returns in under 1ms—which is what happens when you resolve a Promise with static JSON—you're skipping the entire window where your application's loading UX is supposed to live.
Here's what breaks when latency is zero:
- Loading states never render. React commits the success state before the loading component paints. You think your spinner works, but it's never been tested.
- Debounced search race conditions stay hidden. If two requests resolve instantly in sequence, the stale-response guard you wrote never triggers. In production, the first request resolves after the second one, and your UI shows wrong data.
- Timeout logic is untested. If your fetch wrapper has a 10-second abort threshold, you have no way to verify it locally without artificially blocking the event loop.
- Optimistic UI rollback paths are invisible. You can't test what happens when an optimistic update needs to be rolled back because the "failure" never occurs.
The fix isn't complicated, but it requires intercepting fetch at the right layer and introducing latency that mirrors real conditions—not just a flat delay.
Step 1: Intercept fetch at the Application Boundary, Not the Component
The first mistake teams make is scattering mock logic inside individual components or custom hooks. You'll see something like this buried in a useUser hook:
// The wrong approach: mock logic inside business code
const fetchUser = async (id) => {
if (import.meta.env.DEV) {
await new Promise(r => setTimeout(r, 200));
return { id, name: "Test User" };
}
return fetch(`/api/users/${id}`).then(r => r.json());
};
This couples test infrastructure to application logic. Every new endpoint needs its own mock block. Latency values are hardcoded and inconsistent. And when you want to simulate a slow network for all requests, you have to touch dozens of files.
Instead, intercept fetch globally at the window level. This keeps your application code clean and centralizes control:
// Intercept once, at app startup
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input.url;
// Route to mock handler if needed
if (shouldMock(url)) {
return mockHandler(url, init);
}
return originalFetch(input, init);
};
This pattern lets you control latency, response status, and payload from a single location—without your components ever knowing they're in a mock environment.
Step 2: Add Configurable Latency, Not a Hardcoded Sleep
The second pitfall: developers add setTimeout with a fixed delay and call it done. A flat 500ms delay on every request doesn't reflect reality. Different endpoints have different latency profiles. A list endpoint paginating 500 records will be slower than a config endpoint returning 200 bytes.
Build a latency profile system that maps routes to realistic delay ranges:
const latencyProfiles = {
'/api/users': { min: 120, max: 280 },
'/api/users/:id': { min: 80, max: 180 },
'/api/search': { min: 300, max: 650 },
'/api/upload': { min: 800, max: 2000 },
};
function getLatency(url) {
const profile = matchProfile(url, latencyProfiles);
if (!profile) return 150; // sensible default
const { min, max } = profile;
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
Now every mocked endpoint has a delay that falls within a plausible range. The search endpoint occasionally takes 650ms—long enough to trigger your debounced loading state. The user detail endpoint is quick but not instant. This is the level of fidelity that separates a toy mock from one that actually catches bugs.
Step 3: Introduce Jitter and Occasional Failures
Real networks don't just vary in speed—they fail. A common mistake in local development mock setups is that they only simulate the happy path with variable timing. They never simulate dropped connections, 500 errors, or timeouts.
Add a failure injection layer:
async function mockHandler(url, init) {
const latency = getLatency(url);
await delay(latency);
// 5% chance of a 500 error on non-GET requests
if (init?.method && init.method !== 'GET' && Math.random() < 0.05) {
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
// 2% chance of a network-level failure (simulates dropped connection)
if (Math.random() < 0.02) {
throw new TypeError('Failed to fetch');
}
const payload = getMockPayload(url);
return new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
With this setup, roughly 1 in 20 write operations will fail locally. Your error toasts, retry buttons, and optimistic rollback logic all get exercised during normal development. You'll catch bugs before QA does—and long before users do.
Step 4: Simulate Network Conditions by Profile, Not by Guess
Here's where most API mock tools stop short: they let you set a delay, but they don't let you switch between network profiles. Fast WiFi, 4G, 3G, and offline conditions all produce different latency characteristics, and your UI should handle all of them.
Expose a global toggle that adjusts all latency profiles at once:
const networkConditions = {
fast: { multiplier: 0.5, failureRate: 0.01 },
'4g': { multiplier: 1.0, failureRate: 0.02 },
'3g': { multiplier: 3.5, failureRate: 0.05 },
'slow-3g': { multiplier: 8.0, failureRate: 0.08 },
};
let currentCondition = '4g';
// Expose for devtools console or a debug panel
window.__setNetworkCondition = (condition) => {
currentCondition = condition;
};
function getLatency(url) {
const base = getBaseLatency(url);
const { multiplier } = networkConditions[currentCondition];
return Math.floor(base * multiplier);
}
Now you can type window.__setNetworkCondition('slow-3g') in your console and watch your application deal with 2-3 second response times. If your loading skeleton doesn't appear, your retry button doesn't show up after a threshold, or your form submission appears to hang indefinitely—you've found a bug that would have shipped to production.
Step 5: Don't Forget AbortController and Request Cancellation
This is the most overlooked mistake in fetch interception for mocks. In production, users navigate away, type new search queries, or close modals while requests are in flight. Your application should be aborting those requests via AbortController. But if your mock resolves instantly, the abort signal never fires during the request window—so you never test whether your abort logic actually works.
With proper latency simulation in place, abort signals become testable. When a user types a new search query 200ms after the first one, and your first request has a 400ms delay, the abort signal will fire while the mock is still "in flight." If your mock doesn't respect the signal, you'll see stale data flash in the UI.
Make your mock abort-aware:
async function mockHandler(url, init) {
const latency = getLatency(url);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
const payload = getMockPayload(url);
resolve(new Response(JSON.stringify(payload), {
status: 200,
headers: { 'Content-Type': 'application/json' }
}));
}, latency);
// Respect abort signal
init?.signal?.addEventListener('abort', () => {
clearTimeout(timer);
reject(new DOMException('The operation was aborted.', 'AbortError'));
});
});
}
Now your race condition guards, cleanup effects, and AbortController wiring are all tested against realistic timing. This is the single biggest quality gap between "works on my machine" and "works in production."
Know When to Reach for a Full Mock Service
Hand-rolled fetch interception is powerful for latency simulation, but it has limits. If you need to mock dozens of endpoints, share mock definitions across a team, or run the same mocks in automated tests, maintaining a custom interceptor becomes a liability. That's when you reach for dedicated api mock tools that support latency configuration out of the box—tools like MSW (Mock Service Worker), Mockoon, or MirageJS.
The key is to choose a tool that lets you configure per-route latency, supports network condition presets, and respects AbortController. If the tool only offers a global delay slider with no per-route control, you're back to the flat-delay problem we started with.
Whether you use a hand-rolled interceptor or a full mock service, the principle is the same: if your mock doesn't simulate latency, you're not testing your UI. You're testing a fantasy version of it. And production is never a fantasy.
Frequently Asked Questions
How do I simulate network latency in local development?
You can simulate network latency by intercepting fetch requests in your JavaScript code and wrapping the response in a setTimeout function. Alternatively, using an API mock tool allows you to configure custom delay thresholds globally or per endpoint. This helps ensure your UI handles slow connections gracefully.
How can I intercept fetch API requests to add a delay?
You can override the native window.fetch method with a custom function that delays the response resolution using a Promise and setTimeout. Many API mocking libraries provide built-in wrappers that make intercepting fetch calls and injecting delays much easier. This approach ensures all your local network requests are artificially slowed down.
Why should I test slow network requests during local development?
Testing slow network requests allows you to verify that your application's loading states, error boundaries, and retry logic work as expected. Since local development networks are usually instant, simulating latency prevents unexpected UI crashes when real users face slower connections. It ultimately leads to a much more robust user experience.
What is the best way to mock API response time in JavaScript?
The best way is to use a dedicated API mock tool like MSW (Mock Service Worker) or Mirage JS, which offer simple configuration options for artificial delays. These tools intercept requests at the network level without modifying your application code. You can easily set specific response times for different routes to test various scenarios.
Can I use Service Workers to simulate network latency?
Yes, Service Workers are excellent for intercepting network requests and simulating latency because they sit between your web application and the network. Tools like MSW utilize Service Workers to delay responses without requiring changes to your frontend code. This makes it a highly reliable method for local development testing.
How do I add a delay to mock API responses using MSW?
In Mock Service Worker (MSW), you can use the built-in delay() utility inside your response resolver to pause the response. You simply import delay from the msw package and call it before returning the mocked JSON response. This allows you to accurately simulate real-world API latency for your specific endpoints.
How do I simulate a slow 3G network for fetch requests?
You can simulate a slow 3G network by applying a custom delay of around 500-1000 milliseconds to your intercepted fetch requests. Alternatively, browser DevTools like Chrome offer a network throttling feature that automatically slows down all network activity. Using an API mock tool, however, gives you more precise control over specific API endpoints.
Is there a way to throttle network requests in Chrome DevTools instead of mocking?
Yes, Chrome DevTools has a built-in Network tab where you can select predefined throttling profiles like Slow 3G or create custom ones. While this slows down all resources, it doesn't offer the granular per-endpoint control that fetch API interception provides. Developers often use a combination of both for comprehensive testing.
How do I test loading states with simulated fetch latency?
By intercepting your fetch API calls and adding an artificial delay, you force your application to stay in its loading state for a longer period. This gives you time to visually inspect spinners, skeleton loaders, and disabled buttons to ensure they render correctly. It guarantees your users won't experience broken layouts while waiting for data.
Can I randomly fail fetch requests to test error handling?
Yes, most API mock tools allow you to write custom logic within your fetch interceptors to randomly reject promises or return 500 status codes. By combining random failures with simulated latency, you can thoroughly test your application's retry mechanisms and error boundaries. This ensures your app remains stable even under poor network conditions.