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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const startAnalysis = () => {
fetchWithObservable<AnalyzePostResponse>(`${url}/analyze`, {
body: JSON.stringify({ message: inputText }),
method: "POST",
})
.pipe(
switchMap((response) =>
fetchWithObservable<AnalyzeGetResponse>(
`${url}/analyze/${response.id}`,
{ method: "GET" }
).pipe(
repeat({ delay: 1000 }),
takeWhile((response) => response.status === "inProgress", true)
)
)
)
.subscribe((response) => {
setResult(response);
}, console.error);
};

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export const fetchWithObservable = <T>(
url: string,
options: RequestInit
): Observable<T> => {
const controller = new AbortController();
const optionsWithAbort = {
...options,
signal: controller.signal,
};

return new Observable<T>((subscriber) => {
fetch(url, optionsWithAbort)
.then((response) => response.json())
.then((json) => {
subscriber.next(json);
subscriber.complete();
})
.catch((error) => {
subscriber.error(error);
});

return () => {
controller.abort();
};
});
};

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
2
3
const controller = new AbortController();
controller.abort();
fetch("https://google.com", { signal: controller.signal });
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
export const fetchWithObservable = <T>(
url: string,
options: RequestInit
): Observable<T> => {
return new Observable<T>((subscriber) => {
const controller = new AbortController();
const optionsWithAbort = {
...options,
signal: controller.signal,
};

fetch(url, optionsWithAbort)
.then((response) => response.json())
.then((json) => {
subscriber.next(json);
subscriber.complete();
})
.catch((error) => {
subscriber.error(error);
});

return () => {
controller.abort();
};
});
};

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:

  1. The Observable gets subscribed. fetch gets called for the first time.
  2. The Observable completes, unsubscribe function gets called which results in calling controller.abort().
  3. repeat operator waits 1000 ms and subscribes again.
  4. The subscribtion results in another fetch call. However, an aborted controller 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.