The Infinite API Loop That Almost Crashed My Browser

2month back i was working on an infinite scroll feature and thought I'd be done in an hour.
The requirement was simple: when the user reaches the bottom of the page, fetch the next set of records.
I've implemented this many times before, so I wasn't expecting any surprises.
A few minutes later, Chrome's Network tab was filled with API requests. My laptop fan started spinning harder than usual, and the page became noticeably slow.
Something was wrong.
After a bit of debugging, I found that my API was returning a 401 Unauthorized error. That wasn't the interesting part though.
The real issue was what happened after the error.
I was using an IntersectionObserver with a sentinel div at the bottom of the page.
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
fetchUsers();
}
});
When the API failed, no new data was added to the list. Since the list didn't grow, the sentinel stayed visible on the screen.
And because it stayed visible, the observer kept firing.
Again.
And again.
And again.
Within seconds, the application was sending hundreds of failed requests.
At first, I thought the problem was in the API layer. Then I checked authentication. Then I started looking at request interceptors.
None of those were the real problem.
The bug was actually in my frontend logic.
I had built the success path, but I hadn't thought enough about the failure path.
The fix ended up being pretty small.
I introduced a hasMore flag and immediately stopped observing when an error occurred or when there was no more data to load.
Something like this:
const fetchUsers = async () => {
try {
const response = await getUsers();
if (!response.data.length) {
setHasMore(false);
return;
}
setUsers(prev => [...prev, ...response.data]);
} catch (error) {
setHasMore(false);
}
};
And before triggering another request:
if (entry.isIntersecting && hasMore && !isLoading) {
fetchUsers();
}
That completely stopped the loop.
While debugging this, I found another issue that was unrelated but equally annoying.
The backend was sending proficiency values like:
{
"proficiency": "ADVANCED"
}
But the frontend expected values like advanced, intermediate, and beginner.
Everything looked fine until a component tried to render a value it didn't recognize.
The fix was just a mapping layer, but it reminded me how fragile UIs can become when frontend and backend contracts aren't perfectly aligned.
One thing that helped a lot during debugging was having a clean separation between controller, service, and repository layers.
I didn't have to jump through random files trying to figure out where the issue was. I could quickly verify whether the problem was in the API call, the data transformation, or the UI behavior.
Looking back, the bug wasn't complicated at all.
What made it tricky was that everything worked perfectly when the API succeeded. The issue only appeared when something failed.
That's probably the biggest lesson I took away from this.
As a Frontend Specialist, it's easy to focus on getting features working. But the real challenge is making sure they behave correctly when things don't go as planned.
That's where having a Product Mindset helps. Real users don't always have perfect network conditions, valid tokens, or predictable data. A good Product Engineer thinks about those scenarios before they become production issues.
A missing condition took me a while to find, but it was a good reminder that sometimes the smallest edge cases create the biggest headaches.

