Detect Network Failures When Using Fetch
I’ve been a web developer for nearly 12 years, and for about 10 of those years I’ve been able to avoid thinking about network reliability as My Problem. If my user’s internet connection was slow or unreliable then they couldn’t expect the internet to work, and I didn’t have to worry about it.
There are lots of good reasons that that isn’t true anymore, but for the most part web developers are able to get away with the old mindset and most (western) users won’t mind. I, however, in the last two years have been asked to create and support and React Native application that must work in no network and poor network conditions. So I dove head first into the world of offline first application development and design, and what I found is that that world is fairly small, and it’s hard to get help with specific technical problems (there are good communities, though, check out http://offlinefirst.org/).
So I want to write posts about problems I have encountered and lessons I’ve learned to help anybody following in my footsteps on React Native.
Lesson 1 : You must detect and handle network failures
It turns out that “poor network” is much harder to deal with than “no network”. If you know you don’t have a network connection you just don’t make network requests and you serve everything from your client side data store, but if your user has a poor network you’re going to have unpredictable network failures.
In modern web apps and in React Native we use fetch() to interact with the network, and it turns out, fetch is quite bad at handling network failures. I’ve ended up doing a lot of testing to discover and handle them.
Client Side Timeouts are not Optional
…and fetch doesn’t help you here. Unlike xhr, fetch has no timeout handling. This is easy enough, you’re probably using ky or your own fetch wrapper (here’s mine) that provides timeouts. If you’re using fetch directly then adding timeout handling can be done like this:
try {
const response = await Promise.race([
fetch(request),
new Promise((_, reject) => setTimeout(
() => reject(new Error('Timeout'),
10000
)),
]);
} catch (e) {
if (error.message === 'Timeout') {
// retry
} else {
throw e; // rethrow other unexpected errors
}
}
Easy! Next…
Fetch throws “Network request failed”
This is annoying because this error is thrown by fetch whenever something fails, but there’s no information about what failed. This error will be thrown if you attempt to issue a GET request to “google.comm” — an obvious typo. But fetch will also throw this error if your network connection is interrupted, including your user turning off their WIFI in the middle of a request.
That means that in production you must catch and handle this error (probably by retrying the request) even though that means that in development you’ll be hiding a potentially useful error if you fat-finger a domain name when making a request. You can always console.error
it I guess.
Another really important piece of information about this is that the server may have successfully processed the request! If your connection was dropped after the request is received by the server then it may not know that the client isn’t listening anymore and keep working away.
Here’s my request now:
try {
const response = await Promise.race([
fetch(request),
new Promise((_, reject) => setTimeout(
() => reject(new Error('Timeout'),
10000
)),
]);
} catch (e) {
if (error.message === 'Timeout'
|| error.message === 'Network request failed') {
// retry
} else {
throw e; // rethrow other unexpected errors
}
}
Fetch Resolves with an Empty Body
This is the absolute worst one. I think this happens when a network request is interrupted while streaming the response, where the other failures occur while streaming the initial request itself.
When this happens the fetch()
promise will resolve, and the response.status
will be 200, but the response.body()
will be empty. There is no blanket solution to this problem that applies to every request. If it’s a POST request (not idempotent) then you probably don’t want to retry it, but also you can’t retry it unless you can guarantee that the second POST will fail with a 4xx. If it’s a PUT request that is idempotent you can just retry it, even though you probably don’t need to, but if it’s a GET request fetching data that you need then you have no recourse but to try it again.
Another thing worth pointing out is that just because the request body is empty doesn’t mean you’ve encountered this error. Sometimes the body is just empty. There’s no blanket solution for even detecting this problem, it varies from request to request.
Luckily for me I control my app’s API, so all of my requests are idempotent and all of my responses have a content-type of application/json, and no endpoints return empty bodies. If i do response.json() and it throws JSON.parse: unexpected character at line 1 column 1 of the JSON data
then I know I’ve encountered this failure. I don’t like watching for that string so I end up with a request like this:
let response;try {
response = await Promise.race([
fetch(request),
new Promise((_, reject) => setTimeout(
() => reject(new Error('Timeout'),
10000
)),
]);
} catch (e) {
if (error.message === 'Timeout'
|| error.message === 'Network request failed') {
// retry
} else {
throw e; // rethrow other unexpected errors
}
}try {
const body = await response.json();
} catch (e) {
// just retry, even if it's a PUT request
}
And that’s it! That’s a lot of code for one network request.
Conclusion: you have to write a fetch wrapper, and be idempotent if you can
To my knowledge, no fetch libraries are helping with this right now, but to be fair, I’m only really aware of one popular good fetch library (ky), and I’m really not that familiar with it so I’m happy to be proven wrong.
The reality is that if you’re interested in delivering a good experience to users with poor networks, you won’t get away without wrapping all your network requests with some sort of an abstraction, and understanding these failures and handling them will be the crucial first step in that. Or you can be like me, and spend a year pulling your hair out chasing down impossible to reproduce state bugs.
You will make your life a lot easier if you only use idempotent api requests. I’ll bring this up in another post, but idempotent api requests are an important cornerstone of a sane offline first design strategy, and you can see it starting already here in understanding how to handle failures.
Good luck out there!