A fetch with no timeout waits as long as the browser is willing to, which on a bad mobile connection is a very long time. There is no timeout option, so the timeout has to be another promise.
const timeout = (ms) => new Promise((_, reject) =>
setTimeout(() => reject(new Error('timeout')), ms)
);
Promise.race([fetch(url), timeout(5000)])
.then(handle)
.catch(report);
race settles with whichever finishes first, so the timeout wins if the request is slow. What it does not do is cancel the request — the fetch continues in the background and its response is discarded, which matters if it has side effects on the server. Clearing the timer in a finally is worth doing too, or a five-second timer is kept alive for every fast request.