JavaScript recursive re-try-catch

Pablo Garcia
3 min readJul 25, 2022

--

Sometimes we want to re-try an action multiple times before throwing an error or giving an action item to the user. For example, with an infinite scroll loading items for a chat or a news feed.

In a few words; we want to find a scalable solution to code that behaves similarly to:

try { fetchPosts() } catch (error) {
try { fetchPosts() } catch (error) {
try { fetchPosts() } catch (error) {
throw error
}
}
}

Code

It is easy to identify the recursion pattern in the code above. So, the solution looks like:

export async function reTryCatch(callback, times = 1) {
try {
return await callback()
} catch (error) {
if (times > 0) {
return await reTryCatch(callback, times - 1)
} else {
throw error
}
}
}

Note, if you need to abort, look at the Shortcomings section below.

The code above tries to execute a callback and it will retry if it errors out and the number of times is greater than 0, otherwise it will throw.

And of course, some helpers:

// Helper
// Tries twice, short for: reTryCatch(callback, 2)
// You can do the same with thrice, etc
export const reTryTwice = async (cb) => await reTryCatch(cb, 2)
// Helper
// Tries and ignores errors if any occur
export const reTrySafe = async (cb, times) => {
try {
return await reTryCatch(cb, times)
} catch(error) {
return null
}
}

Usage

reTryCatch(async () => await fetch('/api/messages', ...), 3)
.catch(error => ...)

Note that the callback does not have to be async. If it is synchronous, it will simply run synchronously:

try {
reTryCatch(() => stuff(...), 3)
} catch (error) { ... }

And you can use the same syntax for async:

async function fetchUser(id) {
try {
const user = reTryCatch(
() => await fetch(`/api/user/${id}`, ...),
3
)
} catch (error) { ... }
}

Shortcomings

There are three fundamental issues/notes with this implementation.

First, there is no straightforward way to revert when there is an error, so if the callback passed to re-try-catch is not an atomic or transactional operation, you’ll have to do some state upkeeping yourself to prevent any side effects so that the app is not left in an unstable state.

reTryCatch(async () => {
setLoading(true)
const stuff = await fetch('/api/stuff', ...)
}).finally((error) => setLoading(false)) // upkeeping

Second, —for obvious reasons— you cannot have a flag inside the callback:

reTryCatch(async () => {
if(loading) return
setLoading(true)
await fetch('/api/stuff', ...)
}).finally(() => setLoading(false))

If it is not that obvious; the callback holds a closure to loading and it being a primitive value it is not passed by reference, and it will always be false because the callback does not change throughout the retries. Even if you passed a reference to loading, this would also break because the finally phase is only executed AFTER the last attempt to retry.

The way to solve this is by extracting the flag:

<button onClick={() => {
if(!loading) {
setLoading(true)
reTryCatch(async () => await fetch('/api/stuff'))
.finally(() => setLoading(false))
}
}>
{loading ? <Spinner /> : "Fetch stuff"}
</button>

Lastly, there is also no straightforward way to abort a re-try-catch. For example, if you are fetching messages for a chat but the user leaves the conversation before the server responds, then you will still be attempting to retry that action —N times— if and when it fails, even if the response is no longer needed.

One way we could hack/solve this is by using first-class functions to prevent the code from being retried. We can add callback.abortedto the catch before attempting to retry but that doesn’t work well if the callback is reused in various places because now your callback holds a shared state. So, we must abort the current fetch call by using the AbortController API:

const ABORTED = 'ABORTED'
const fetchStuffController = new AbortController()
async function fetchStuff() {
const response = await fetch('/api/stuff', {
signal: fetchStuffController.signal
})
if(response.status === 499) {
return ABORTED
}
return await response.json()
}
// somewhere
try {
const stuff = reTryCatch(fetchStuff)
if(stuff !== ABORTED) {
setStuff(stuff)
}
} catch(error) {
alert('There was a problem fetching the stuff!')
}
// somewhere else
fetchStuffController.abort()

--

--

Pablo Garcia

Senior Engineer at Netflix, ex-Staff Architect 2 at PayPal. M.S. in Computer Science w/specialization in Computing Systems. B.Eng. in Computer Software.