Create your own
Lesson illustration

Resilient Reconnection with Exponential Backoff

Hello! Welcome back to our module on "Real-World Architectural Patterns."

In our previous lesson, we successfully wrapped the WebSocket API in an RxJS Observable, creating a webSocket$ stream using the webSocket factory function. This gave us a powerful, shared, and bi-directional channel for real-time communication.

However, network connections are inherently unreliable. If our WebSocket connection drops, the webSocket$ Observable will emit an error and terminate, leaving our application without real-time updates. A truly robust application must be able to recover from such failures gracefully.

Today, we will address this exact challenge. Our learning outcome is to implement a robust automatic reconnection strategy with exponential backoff for a failing Observable stream (e.g., WebSocket).

We will learn how to use the retry operator to automatically resubscribe to our webSocket$ stream upon failure. More importantly, we'll move beyond simple retries to implement a sophisticated exponential backoff strategy, which intelligently spaces out reconnection attempts to avoid overwhelming a recovering server.

1. The Problem with Failing Streams and Simple Retries

When an Observable like our webSocket$ encounters a terminal event (an error or completion), it stops emitting and cleans up its resources. Any subscribers will be notified of the error, but the stream is effectively dead.

The simplest way to handle this is with the retry operator.

import { webSocket } from 'rxjs/webSocket';
import { retry } from 'rxjs';

const ws$ = webSocket('wss://your-faulty-socket.com/ws');

ws$.pipe(
  retry(3) // On error, resubscribe up to 3 times
).subscribe({
  next: msg => console.log(msg),
  error: err => console.error('Gave up after 3 retries:', err)
});

In this example, if the WebSocket connection fails, RxJS will immediately try to reconnect by resubscribing to the source ws$. It will do this up to three times.

While simple, this approach has two major flaws:

  1. Immediate Retries: It retries instantly, which can hammer a server that is temporarily down or restarting, a pattern sometimes called a "thundering herd."
  2. Fixed Delay: We can add a fixed delay, but this is still not ideal. A short delay might not be long enough for the server to recover, while a long delay makes our application unresponsive.

Reactive + Functional UI Patterns in TypeScript and F#: RxJS ...

This article on reactive UI patterns provides a concise look at using retry for error handling. Let's start with its simplest form.

Read the subsection '3.5.1 Simple retry with delay'. It demonstrates the basic retry({ delay: 2000 }) pattern, which is a step up from immediate retries but still lacks sophistication.

We need a smarter strategy that adapts its timing. This is where exponential backoff comes in.

2. Implementing Exponential Backoff with retry

Exponential backoff is an algorithm that increases the delay between retries exponentially. For example:

  • Attempt 1 fails -> wait 1 second.
  • Attempt 2 fails -> wait 2 seconds.
  • Attempt 3 fails -> wait 4 seconds.
  • And so on.

This strategy gives a struggling server progressively more time to recover and is the standard for building resilient network clients.

Since RxJS v7, the retry operator has a powerful configuration object that allows its delay property to be a function. This function receives the error and the current retry count, and it must return an Observable that dictates the delay. This is perfect for implementing custom backoff logic.

Error Handling in Angular - Complete Guide (2022)

This video, while set in an Angular context, contains an excellent, framework-agnostic demonstration of implementing a progressive retry strategy using the retry operator. Focus on how the operator is configured.

Watch from 31:50 to 33:15. Pay close attention to how the retry operator is passed a configuration object. The key part is the delay function, which takes the attempt count and returns a timer Observable. This is the core mechanism for creating a custom retry schedule.

The video demonstrates a linear backoff (attempt * 1000). To achieve exponential backoff, we simply need to adjust the calculation inside the delay function using Math.pow().

3. Building a "Robust" Reconnection Strategy

A truly robust strategy involves more than just an exponential delay. We need to consider several factors:

  • Maximum Retries: To avoid retrying forever.
  • Maximum Delay: To cap the delay at a reasonable maximum.
  • Conditional Retries: To stop retrying for certain types of errors (e.g., an authentication failure is permanent and shouldn't be retried).
  • Jitter: To add a small amount of randomness to the delay. If thousands of clients are reconnecting, jitter prevents them from all retrying at the exact same synchronized intervals.

Power of RxJS when using exponential backoff

This article, 'Power of RxJS when using exponential backoff', discusses a library that encapsulates this logic. While we will build it ourselves, the configuration options it presents are the exact concepts that define a robust strategy.

Read the section '1. retryBackoff' and focus on the 'RetryBackoffConfig' properties: initialInterval, maxRetries, maxInterval, and shouldRetry. These are the building blocks of our robust implementation. You can skim the parts about the specific backoff-rxjs library itself.

Let's combine these concepts into a single, robust delay function for our retry operator. We will implement all this logic ourselves, without any external libraries.

The Complete Example

Here is how you can apply a robust reconnection strategy to the webSocket$ Observable from our last lesson.

import { webSocket } from 'rxjs/webSocket';
import { retry, timer, throwError, tap } from 'rxjs';

// --- Configuration for our backoff strategy ---
const MAX_RETRIES = 5;
const INITIAL_DELAY_MS = 1000;

// --- Our WebSocket stream ---
const ws$ = webSocket({
  url: 'wss://echo.websocket.events', // A public test WebSocket
  closeObserver: {
    next: (closeEvent) => {
      console.log(`WebSocket closed with code: ${closeEvent.code}`);
      // You can decide whether to throw an error here to trigger a retry
      // For example, for abnormal closures.
      if (!closeEvent.wasClean) {
        // This will be caught by the retry operator
        throw closeEvent;
      }
    }
  }
});

// --- Applying the robust retry logic ---
ws$.pipe(
  tap({
    subscribe: () => console.log('Attempting to connect to WebSocket...'),
    error: err => console.error('WebSocket error before retry logic:', err)
  }),
  retry({
    delay: (error, retryCount) => {
      console.log(`Encountered error:`, error);
      
      // 1. Conditional Retry Logic (shouldRetry)
      // Example: Stop retrying on a specific WebSocket close code
      if (error.code === 1008 /* Policy Violation */) {
        console.error('Policy violation. Will not retry.');
        return throwError(() => error); // Propagate the error to stop
      }

      // 2. Max Retries Logic
      if (retryCount > MAX_RETRIES) {
        console.error(`Max retries (${MAX_RETRIES}) reached. Giving up.`);
        return throwError(() => error); // Propagate the error to stop
      }

      // 3. Exponential Backoff Calculation
      const backoffMs = INITIAL_DELAY_MS * Math.pow(2, retryCount - 1);

      // 4. Jitter (add randomness to prevent thundering herd)
      const jitterMs = backoffMs * 0.2 * Math.random(); // +/- 10%
      const totalDelayMs = backoffMs + jitterMs;

      console.log(`Attempt ${retryCount}: Retrying in ${Math.round(totalDelayMs)}ms...`);
      
      // 5. Return the timer
      return timer(totalDelayMs);
    }
  })
).subscribe({
  next: msg => console.log('Received message:', msg),
  error: err => console.error('Subscription stopped permanently. Final error:', err),
  complete: () => console.log('Stream completed cleanly.') // Will be called if the source completes without error
});

// To test, you can send a message. The echo server will send it back.
// If you disconnect your internet, you will see the retry logic kick in.
setTimeout(() => ws$.next('Hello, WebSocket!'), 1000);

This single, declarative pipeline now handles:

  • Connecting to the WebSocket.
  • Logging connection attempts.
  • Detecting failures.
  • Intelligently deciding if and when to retry based on our custom logic.
  • Giving up after a configured number of attempts.

This is a prime example of how RxJS allows you to compose complex, asynchronous behavior in a readable and maintainable way—a task that would be significantly more complex using traditional callbacks or promises.


Conclusion

Today we've transformed our simple WebSocket stream into a resilient, production-ready data source.

Key Takeaways:

  • The retry operator is the primary tool for automatically re-subscribing to a failed Observable stream.
  • A simple retry(N) is often insufficient for network requests as it can overwhelm a recovering server.
  • Exponential backoff is the preferred strategy, progressively increasing the delay between retries.
  • A robust implementation can be achieved using the retry operator's delay function, which allows you to define a custom schedule based on the error and retry count.
  • Key features of a robust strategy include max retries, conditional logic (don't retry on permanent errors), and jitter (randomness to de-synchronize clients).

Next Lesson Preview:

Now that we have a resilient stream that can fetch data even through network failures, what about the data itself? Some data is expensive to fetch. Constantly re-fetching it can be wasteful, especially if multiple parts of your application need it.

In our next lesson, we will tackle this by learning how to build an in-memory cache for API requests with time-based invalidation using shareReplay. This will allow us to share the result of a single stream execution among multiple subscribers, preventing redundant work and further improving our application's performance and architecture.

Can't find a good explanation? Sign up and we'll make it for you

Sign up