October 28, 2026
My Frontend Looked Great Until the API Started Failing
I used to think a polished UI meant my frontend was almost finished. Then the API started returning errors, requests took forever, and myโฆ
By Muhummad Zaki
12 min read
- 1 1. I Stopped Assuming Every API Request Would Succeed
- 2 2. I Learned That Loading, Empty, and Error States Are Different Problems
- 3 3. I Started Treating API Data as Untrusted Input
- 4 4. I Stopped Writing try...catch Blocks Without a Recovery Plan
- 5 5. I Realized That Slow APIs Can Be Just as Damaging as Broken APIs
I used to think a polished UI meant my frontend was almost finished. Then the API started returning errors, requests took forever, and my beautiful interface had no idea what to do next.
The page looked perfect.
_๐ฅ _Master AI & Tech Skills in 3 Months! ๐ฐ One Subscription. Multiple Courses. More Savings. ๐ฅ Get Up to 50% OFF | โฐ Limited-Time Offer ๐ Enroll Now & Start Learning
The spacing was right. The buttons worked. The loading animation appeared exactly where I wanted it. I had spent hours adjusting small details that most users would probably never notice.
Then I connected the frontend to the real API.
Suddenly, the user list was empty. A button that worked perfectly yesterday did nothing useful. Sometimes the page displayed old data, sometimes it showed nothing, and occasionally the entire interface looked broken because one request failed.
My first instinct was to inspect the frontend.
I checked the component state, reviewed the event handlers, and changed a few lines of code. Nothing fixed the actual problem.
The API was failing, and my frontend had been built around the assumption that it never would.
That experience changed how I approach frontend development. I stopped treating API integration as the final step after building a UI. Instead, I started treating network failures, slow responses, unexpected data, and expired sessions as normal conditions that the interface must handle.
Here are the lessons and techniques that made the biggest difference in my development workflow.
1. I Stopped Assuming Every API Request Would Succeed
One of my earliest mistakes was writing frontend code that expected the backend to return exactly what I wanted.
For example, a typical request might look like this:
const response = await fetch("/api/users");
const users = await response.json();
setUsers(users);It is short, readable, and perfectly reasonable for a quick demonstration.const response = await fetch("/api/users");
const users = await response.json();
setUsers(users);It is short, readable, and perfectly reasonable for a quick demonstration.But production applications are not demonstrations.
The server might return a 500 Internal Server Error. The user might lose their internet connection. The request might return an HTML error page instead of JSON. Even worse, the server might respond with a 404 while fetch() still resolves successfully.
That last detail is particularly important: fetch() does not reject its promise just because the server returns an HTTP error status.
I now make HTTP status handling explicit.
async function getUsers() {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}async function getUsers() {
const response = await fetch("/api/users");
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json();
}This small change creates a much clearer boundary between successful data retrieval and failure.
The calling component can now decide whether to show an error message, retry the request, or display previously loaded data.
The larger lesson is that an API response is not automatically valid application data just because a request completed.
A successful network connection and a successful application operation are two different things.
2. I Learned That Loading, Empty, and Error States Are Different Problems
I used to think that showing a loading spinner was enough to make an interface feel responsive.
It wasn't.
A spinner tells the user that something is happening. It does not tell them what happened when the request finishes with no results, or when it never finishes successfully.
Consider a dashboard that displays customer records.
There are at least four meaningful states:
- Loading: The application is waiting for the response.
- Success: The server returned valid customer data.
- Empty: The request succeeded, but there are no customers to display.
- Error: The application could not retrieve the records.
Treating these states separately prevents confusing interfaces.
Here is a compact React example:
if (loading) return <p>Loading customers...</p>;
if (error) return <p>Could not load customers.</p>;
if (customers.length === 0) return <p>No customers yet.</p>;
return <CustomerList customers={customers} />;The example assumes that loading, error, and customers are managed correctly in the surrounding component.if (loading) return <p>Loading customers...</p>;
if (error) return <p>Could not load customers.</p>;
if (customers.length === 0) return <p>No customers yet.</p>;
return <CustomerList customers={customers} />;The example assumes that loading, error, and customers are managed correctly in the surrounding component.Notice what this avoids: showing an empty list when the API actually failed.
That distinction matters more than it initially appears. If an administrator opens a dashboard and sees zero orders because the backend is unavailable, they might assume the business has received no orders at all.
A technical failure has now become a misleading business decision.
I started asking a different question whenever I built a component:
What should the user see if the data never arrives?
That question has improved my interfaces more than another round of CSS polishing ever did.
3. I Started Treating API Data as Untrusted Input
One particularly frustrating bug happens when an API returns data that is valid JSON but does not match the structure the frontend expects.
Imagine the interface expects this response:
{
"users": [
{ "id": 1, "name": "Alex" }
]
}{
"users": [
{ "id": 1, "name": "Alex" }
]
}But the backend returns a list directly:
[
{ "id": 1, "name": "Alex" }
][
{ "id": 1, "name": "Alex" }
]Both responses are valid JSON. Only one matches the frontend's assumptions.
Code such as data.users.map(...) will fail when users does not exist.
During development, it is tempting to fix this with a quick fallback:
const users = data.users || [];const users = data.users || [];That can be useful when missing data is genuinely allowed. However, it can also hide a contract violation between the frontend and backend.
If the API is supposed to return an object containing users, silently converting an unexpected response into an empty array makes the application look successful while concealing the real problem.
My approach now depends on the situation.
For optional fields, I use sensible defaults. For required fields, I validate the response and report a meaningful error.
For example:
function parseUsers(data) {
if (!data || !Array.isArray(data.users)) {
throw new Error("Invalid users response");
}
return data.users;
}function parseUsers(data) {
if (!data || !Array.isArray(data.users)) {
throw new Error("Invalid users response");
}
return data.users;
}This is deliberately simple. A larger application may benefit from a schema-validation library, particularly when working with many endpoints.
The important principle is to define what the frontend expects rather than allowing unexpected data to travel through the application unchecked.
Pro tip: Keep your API contract explicit. When the backend changes a field name, response shape, or data type, a clear validation error is much easier to debug than a broken component several layers away.
4. I Stopped Writing try...catch Blocks Without a Recovery Plan
Adding error handling is easy.
Deciding what to do after an error is the difficult part.
I have seen code that catches an exception and logs it to the console, then continues as though nothing happened:
try {
await saveSettings();
} catch (error) {
console.error(error);
}try {
await saveSettings();
} catch (error) {
console.error(error);
}This prevents an unhandled exception from escaping that block, but it does not help the user understand whether their settings were saved.
For a developer, the console contains useful information. For a user, the interface may simply appear unresponsive.
I now try to connect each failure to a specific recovery action.
For example, a settings form might display a message that the save failed while preserving the values the user entered. A search page might offer a retry button. A payment interface might explain that the result could not be confirmed and avoid blindly repeating a potentially successful transaction.
The correct response depends on the operation.
Here is a small example for a user-triggered action:
async function handleSave() {
setSaving(true);
setError("");
try {
await saveSettings();
} catch {
setError("Settings could not be saved. Try again.");
} finally {
setSaving(false);
}
}The surrounding component must define the state variables and saveSettings() function, but the structure illustrates the idea.async function handleSave() {
setSaving(true);
setError("");
try {
await saveSettings();
} catch {
setError("Settings could not be saved. Try again.");
} finally {
setSaving(false);
}
}The surrounding component must define the state variables and saveSettings() function, but the structure illustrates the idea.The finally block ensures that the saving indicator is reset whether the request succeeds or fails.
One subtle detail: a network error does not always mean the server failed to perform the operation. The server might have processed a request before the connection dropped.
That is why blindly retrying operations that create orders, submit payments, or perform other consequential actions can be dangerous. Those workflows may need idempotency keys, server-side deduplication, or a way to check the operation's status before retrying.
Good error handling is not simply about catching exceptions. It is about deciding how the application should recover without creating a second problem.
5. I Realized That Slow APIs Can Be Just as Damaging as Broken APIs
An API does not need to return an error to make a frontend feel broken.
Sometimes it just takes eight seconds to respond.
During those eight seconds, users may click the same button repeatedly, navigate away, submit duplicate forms, or assume the application has stopped working.
A loading indicator helps, but it does not solve every problem.
For requests that can remain pending indefinitely, I started considering timeouts.
The browser's AbortController provides a straightforward way to cancel a fetch request after a deadline:
async function fetchWithTimeout(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
return await fetch(url, {
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
}This example returns the fetch response and clears the timer when the request finishes. A production implementation should also check response.ok, parse the response appropriately, and distinguish an intentional timeout from other failures.async function fetchWithTimeout(url) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
return await fetch(url, {
signal: controller.signal
});
} finally {
clearTimeout(timer);
}
}This example returns the fetch response and clears the timer when the request finishes. A production implementation should also check response.ok, parse the response appropriately, and distinguish an intentional timeout from other failures.There is another important distinction: aborting a browser request does not guarantee that the server stops processing it.
For a read-only request, cancellation is often straightforward. For a write operation, the frontend still needs to consider whether the server may have completed the action.
I also learned not to apply the same timeout to every endpoint.
A quick autocomplete request might need a short deadline. A report that processes thousands of records may legitimately take longer, especially if the server supports asynchronous jobs.
The goal is not to make every request fast. It is to make waiting predictable and give users a sensible next step.
6. I Discovered That Multiple Requests Can Create Race Conditions
This was one of the more subtle problems I encountered while building interfaces around live data.
Imagine a search box that sends requests as the user types.
The user enters py, then quickly changes it to python.
The frontend sends two requests:
- Request A searches for
py. - Request B searches for
python.
You might expect request A to finish first because it started first.
Network requests do not promise that ordering.
Request B might return first, displaying the correct results for python. Then request A could finish and overwrite those results with the broader results for py.
Nothing crashed. The API returned valid data. The UI still displayed the wrong information.
That is what makes race conditions so annoying: the code can appear correct while the timing changes the outcome.
One solution is to cancel the previous request when a new search begins.
let controller;
async function searchUsers(query) {
controller?.abort();
controller = new AbortController();
return fetch(
`/api/users?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
}This demonstrates the core idea, but it needs a few additional safeguards in a real application. For example, the caller must handle abort errors, parse the response, and ensure that the controller belongs to the correct component or search instance.let controller;
async function searchUsers(query) {
controller?.abort();
controller = new AbortController();
return fetch(
`/api/users?q=${encodeURIComponent(query)}`,
{ signal: controller.signal }
);
}This demonstrates the core idea, but it needs a few additional safeguards in a real application. For example, the caller must handle abort errors, parse the response, and ensure that the controller belongs to the correct component or search instance.In React, request cancellation is often best managed inside an effect with cleanup. A data-fetching library can also handle many of these details.
Another option is to assign each request an identifier and ignore results from older requests.
The broader lesson is that network responses can arrive in a different order from the one in which requests were sent.
Whenever an interface supports live search, filters, rapidly changing selections, or automatic refreshes, I consider whether an older response could overwrite newer state.
That one question can save hours of debugging.
7. I Stopped Automatically Retrying Every Failed Request
Retries sound like an obvious solution to temporary network failures.
If the request fails, send it again.
If it fails again, send it one more time.
Simple, right?
Unfortunately, retries can make an overloaded backend even more overloaded. They can also duplicate operations if the original request succeeded on the server but its response never reached the browser.
I now distinguish between requests that are generally safe to retry and operations that require additional protection.
A GET request that retrieves a list of products is usually a reasonable candidate for a limited retry policy. A POST request that creates an order needs more care.
Even read requests should not be retried indefinitely. A broken network connection will not improve because the frontend keeps sending the same request every second.
A practical retry policy usually includes:
- A limited number of attempts.
- A delay between attempts.
- Exponential backoff for repeated failures.
- Random jitter to avoid many clients retrying simultaneously.
- A clear decision about which errors are retryable.
For example, a backoff schedule might use delays of approximately 500 milliseconds, one second, and two seconds, with some random variation.
This gives a temporarily struggling service room to recover.
I also avoid retrying every HTTP error. A 401 Unauthorized may require authentication, while a 400 Bad Request generally requires correcting the request. Repeating either request without changing anything is unlikely to help.
A retry policy should respond to the reason for failure, not just the fact that something failed.
8. I Began Centralizing API Logic Instead of Repeating It Everywhere
At first, putting fetch() directly inside each component felt convenient.
A product component fetched products. A profile component fetched profiles. A settings component submitted settings.
Each component handled its own headers, response parsing, status checks, and error messages.
The problem appeared when I needed to change how requests worked.
I had to update several files, and small differences between those implementations started creating inconsistent behavior.
One endpoint handled 401 responses differently from another. One component checked response.ok; another did not. One request used a timeout, while the others could wait indefinitely.
I started moving common network behavior into a small API helper.
async function apiGet(path) {
const response = await fetch(`/api${path}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}async function apiGet(path) {
const response = await fetch(`/api${path}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json();
}A component can now call apiGet("/users") instead of repeating the same request-handling logic.
This helper is intentionally minimal. A production version may also need authentication handling, request cancellation, content-type checks, structured errors, and response validation.
I would not build a huge API abstraction for a tiny project, either. Abstractions have a cost, and a helper that tries to anticipate every possible networking scenario can become harder to maintain than the code it replaces.
I look for repeated behavior first, then centralize the parts that genuinely need to remain consistent.
That usually gives me a better balance between simplicity and maintainability.
9. I Learned to Debug the Request Before Rewriting the Component
When a frontend displays incorrect data, the component is not always the source of the problem.
The issue could be the request URL, authentication headers, backend response, caching behavior, or even a proxy configuration.
Instead of immediately changing React state or rewriting the component, I now inspect the request itself.
My first stop is the browser's Network panel in developer tools.
I check five things:
- Request URL: Is the frontend calling the correct endpoint?
- Status code: Did the server return success, an authorization error, a missing-resource error, or a server failure?
- Request headers: Are the expected authentication and content-type headers present?
- Response body: Does the server return the structure the frontend expects?
- Timing: Is the request waiting on the server, transferring data slowly, or failing before it reaches the backend?
These checks help narrow the problem before I touch the code.
For example, if the server returns 200 OK but the interface is empty, I investigate response parsing and state updates. If it returns 401, I investigate the authentication flow. If it returns 500, I look for server-side errors rather than trying random CSS changes.
For cross-origin requests, I also check the browser console for CORS errors. A backend may be reachable directly while a browser refuses to expose its response because the server's cross-origin configuration is incorrect.
This approach has a useful side effect: it reduces the number of changes I make without evidence.
Debugging becomes less like guessing and more like eliminating possibilities.
10. I Started Testing Failure Scenarios Before Calling a Feature Finished
A frontend that works when the backend is healthy has passed only part of its test.
I want to know what happens when the request fails, returns malformed data, takes too long, or completes after the user has moved to another screen.
Fortunately, I do not need to break my actual backend to test these cases.
The browser's developer tools can simulate offline conditions and throttle network speed. Mock Service Worker (MSW) can intercept requests during development and tests, making it possible to return controlled responses without changing production endpoints.
For example, a test might simulate a server failure:
server.use(
http.get("/api/users", () => {
return HttpResponse.error();
})
);server.use(
http.get("/api/users", () => {
return HttpResponse.error();
})
);This snippet assumes that MSW's server, http, and HttpResponse have already been configured and imported.
The point is not that every application needs an elaborate testing setup. It is that failure behavior should be reproducible.
I also test the difference between a server error and a successful empty response. They may both leave the screen without any records, but they should not produce the same user experience.
For important workflows, I include checks for duplicate submissions, stale responses, expired sessions, and recovery after the connection returns.
A little deliberate failure during development can prevent a lot of confusion after deployment.
What Changed in My Frontend Workflow
The biggest change was not a new library or a clever piece of JavaScript.
It was the way I defined a finished feature.
Previously, I focused on whether the page looked right and whether the happy path worked. Now I also ask whether the interface behaves predictably when the backend does something unexpected.
That means considering:
- What the user sees while data is loading.
- What happens when a successful response contains no records.
- How the interface responds to invalid or unexpected data.
- Whether a request can remain pending too long.
- Whether an old response can overwrite a newer one.
- Which operations can be retried safely.
- How the application communicates a failure and helps the user recover.
I do not implement every possible safeguard in every component. A small static page does not need the same networking architecture as a financial dashboard or a large application with thousands of users.
But I no longer treat error handling as something to add after the feature works.
It is part of making the feature work.
As the saying goes, hope is not a strategy. Assuming the API will always behave perfectly is not a frontend architecture.
The most reliable interfaces are not necessarily the ones that never encounter errors. They are the ones that handle errors without confusing the user, corrupting the application state, or making recovery harder than it needs to be.
My frontend looked great when the API worked.
Learning to handle the moments when it didn't was what made it feel like a real application.
Thank you for being a part of the community
Before you go:
๐ Be sure to clap and follow the writer ๏ธ๐๏ธ๏ธ
๐ Follow us: Medium
Disclosure: This post includes affiliate and partnership links.