In this article, I'll cover three approaches to implementing short HTTP polling, using client-side JavaScript (TypeScript). Short polling involves making repeated, separate requests to check the status of a resource.
Imagine we have an API with two endpoints: postTask
and getTask
. After posting a task (using the postTask
endpoint), we need to continuously poll the getTask
endpoint until it returns a status of either DONE or FAILED.
We'll start by exploring the Ugly approach.
The first thing that comes into mind is a recursive setTimeout
:
function handlePolling(
taskId: string,
done: (result: string) => void,
failed: (err: string) => void,
): void {
API.getTask(taskId).then(response => {
switch (response.status) {
case 'IN_PROGRESS':
setTimeout(() => handlePolling(taskId, done, failed), 1000);
break;
case 'DONE':
return done(response.result);
case 'FAILED':
return failed('polling failed');
default:
return failed(`unexpected status = ${response.status}`);
}
}).catch(err => {
failed(`getTask failed - ${err}`);
});
}
This approach is quite straightforward but comes with a couple of notable flaws:
Callbacks: done
& failed
.
While this is largely a matter of preference, using callbacks can start to resemble the infamous "callback hell" from early Node.js days. If we tried to return a Promise, we'd encounter a branching structure since each Promise could either resolve or reject. As a result, we're forced to stick with callbacks for simplicity.
Recursion
The bigger issue is that recursion can make debugging more difficult. Each recursive call adds complexity, making it harder to track the flow and pinpoint where things go wrong.
Let’s rewrite it in a more linear fashion with async
& await
:
const sleep = (timeout: number) => new Promise((resolve) => setTimeout(resolve, timeout));
async function handlePolling(taskId: string): Promise<string> {
while (true) {
try {
await sleep(1000);
response = await API.getTask(taskId);
switch (response.status) {
case 'IN_PROGRESS':
continue;
case 'DONE':
return response.result;
case 'FAILED':
return Promise.reject('polling failed');
default:
return Promise.reject(`unexpected status = ${response.status}`);
}
} catch (err) {
return Promise.reject(`getTask failed - ${err}`);
}
}
}
This approach is much cleaner. We've encapsulated setTimeout
within a sleep
function, and we now return a Promise that can be awaited. The code is more readable and easier to maintain.
The only detail that stands out is the while(true)
loop. This can be improved by using while(status === 'IN_PROGRESS')
, which makes the intent clearer. Additionally, it's a good idea to implement a safety mechanism to limit the number of getTask
requests. This helps protect against the rare case where the API might get stuck in an infinite loop or experience an unexpected delay.
The approach I prefer the most is using RxJS — a library specifically designed for handling event streams. You can think of it as “lodash for Promises.” RxJS provides a powerful and flexible way to manage asynchronous events like HTTP polling, making it easier to handle complex workflows in a more declarative manner, e.g.:
function handlePolling(taskId: string) {
return new Promise<string>((resolve, reject) => {
interval(1000).pipe(
switchMap(() => API.getTask(taskId)), // runs the request each time the interval emits
takeWhile(response => response.status === 'IN_PROGRESS', true),
filter(response => response.status !== 'IN_PROGRESS'),
).subscribe({
next: response => {
switch (response.status) {
case 'DONE':
return response.result;
case 'FAILED':
return Promise.reject('polling failed');
default:
return Promise.reject(`unexpected status = ${response.status}`);
}
},
error: err => reject(`getTask failed - ${err}`),
});
}
What I love about RxJS is how straightforward it makes the code. With the takeWhile
operator, we clearly define when to stop the loop, and by using the filter
operator, we can skip handling any responses where the status is still IN_PROGRESS
. This creates a clean, declarative flow that's easy to read and maintain.
Both the "Good" and the "Not-So-Bad" approaches are viable ways to handle HTTP polling. However, if your project involves a significant amount of asynchronous logic — such as polling, interdependent async tasks, background loading or complex calculations — then it’s worth considering adopting RxJS. Its powerful tools for managing streams of asynchronous events can greatly simplify your code and make it more maintainable in the long run.