Some time ago I encountered an interesting bug at work. I believe solving this bug was a great excercise in RxJS fundamentals, therefore I decided to tell you about it.
Context
My colleague was implementing a polling mechanism using RxJS. His solution was similiar to the one described in my blog post about polling. For the sake of this article, let’s assume he wrote the following code. It starts a long running backend operation (an analysis) by sending a POST
call and subseqently polls for the status of the operation (using GET
calls) every second. It stops polling as soon as the status returned by the last GET
call indicates success or failure. If you’re having trouble understanding the code below, please read the article first.
1 | const startAnalysis = () => { |
As you can see, instead of using ajax.getJSON
util provided by RxJS, it is using our custom wrapper around fetch
called fetchWithObservable
. We created it because at that time RxJS didn’t include ajax
utils. Here is the implementation of fetchWithObservable
that we were using at that time.
1 | export const fetchWithObservable = <T>( |
This function returns a new Observable
in which we call fetch
, emit the response (subscriber.next
) and immedietly complete the Observable
(subscriber.complete
). There is also some error handling subscriber.error
and cancellation support. We implemented cancellation by creating an AbortController
and passing controller.signal
as one of the options to the fetch
call. Then, we call controller.abort
in the function returned by the function definieng our Observable
. This function will get called whenever the Observable
is unsubscribed from. It makes sense, because if the caller unsubscribes, we don’t want to waste resources by waiting and processing the HTTP response.
Can you spot a bug in this code? Pause here for a moment and give it a try.
Debugging
So, we tried running this code but it didn’t behave as expected. We expected a single POST
call to the analyze
endpoint followed by multiple GET
calls. Instead, we saw a POST
call followed by a single GET
call. What’s more, we saw the following error in the console.
1 | DOMException: Failed to execute 'fetch' on 'Window': The user aborted a request. |
The exception stacktrace in the debugger indicates that the exception comes from the fetch
call. Because of the repeat
operator, fetch
will get called multiple times. It will always use the same optionsWithAbort
object along with the same controller
.
Since the error message mentions aborting, let’s put a breakpoint on controller.abort()
. It turns out that this line gets called multiple times. Each fetch
is followed by a call to controller.abort()
.
This means that we call fetch
with a controller
that has already been aborted. Let’s see what happens in such case:
1 | const controller = new AbortController(); |
1 | DOMException: Failed to execute 'fetch' on 'Window': The user aborted a request. |
Bingo! We found the root cause - we’re reusing the AbortControler
for multiple fetch
requests. It works for the first time, because the controller is fresh, but it is already aborted when passed to the subsequent calls.
Therefore, the correct implementation is as follows:
1 | export const fetchWithObservable = <T>( |
Explanation
Let’s understand what exactly is happening here. The key piece of the puzzle is the repeat
operator. Let’s check its definition.
Returns an Observable that will resubscribe to the source stream when the source stream completes.
Fair enough. In our case, the source stream is the one returned by fetchWithObservable
. repeat
operator will subscribe to it every second, which will result in a new fetch
call at the same interval.
As we saw in the implementation of fetchWithObservable
, it emits a single value and then completes. However, when an Observable
completes, it unsubscribes all its subscribers. It means that controller.abort()
will get called after each fetch
and before the next fetch
. This results in the behavior we observed.
Here is the exact sequence of events:
- The
Observable
gets subscribed.fetch
gets called for the first time. - The
Observable
completes, unsubscribe function gets called which results in callingcontroller.abort()
. repeat
operator waits 1000 ms and subscribes again.- The subscribtion results in another
fetch
call. However, an abortedcontroller
is passed with options. Exception is thrown.
Summary
Obviously you wouldn’t encounter such a bug when writing new code as you would use RxJS’s ajax
utils. However, still thought it worthwhile to share this story, as it may help you understand how things work under the hood.