Resilience Patterns in TypeScript: Timeouts

February 3, 2025

The web is inherently unreliable. Whether you're calling an external API, performing a complex calculation, or waiting for a user operation to complete, things can go wrong. One of the most insidious issues is when operations simply hang - they neither succeed nor fail, leaving your application in limbo. This is where timeout patterns come to the rescue.

In this post, we'll explore how to implement robust timeout patterns in TypeScript. We'll look at practical examples, best practices, and common pitfalls to avoid. By the end, you'll have a solid understanding of how to make your applications more resilient using timeouts.

What is a Timeout Pattern?

A timeout pattern is a defensive programming technique that sets a maximum time limit for an operation to complete. If the operation doesn't finish within this time limit, it's automatically terminated, and an error is raised. This pattern is crucial for:

  • Preventing indefinite waits
  • Maintaining system responsiveness
  • Failing fast when services are unresponsive
  • Managing resource usage effectively

How Does It Work?

+---------------------+
| Start the Operation |
+----------+----------+
           |
           v
+----------+----------+
|   Set a Timer for   |
|   Timeout Duration  |
+----------+----------+
           |
           v
+----------+----------+
| Monitor the Operation|
+----------+----------+
           |
           +-------------------------------+
           |                               |
           v                               v
+----------+----------+          +----------+----------+
| Operation Completes |          | Timer Expires       |
| Before Timeout      |          | Before Operation    |
+----------+----------+          | Completes           |
           |                     +----------+----------+
           v                               |
+----------+----------+                    v
| Clear the Timer     |          +----------+----------+
| Return Result       |          | Terminate Operation |
+----------+----------+          | Raise Timeout Error |
                                 +---------------------+

This diagram shows the flow of the timeout pattern:

  • Start the operation.
  • Set a timer for the timeout duration.
  • Monitor the operation.
  • If the operation completes before the timeout, clear the timer and return the result.
  • If the timer expires before the operation completes, terminate the operation and raise a timeout error.

Implementation

Let's implement a robust timeout pattern in TypeScript. Our implementation will focus on being:

  • Type-safe
  • Easy to use
  • Flexible for different use cases
  • Well-tested

Here's the implementation with detailed explanations:

/**
 * Custom error class for timeout-related exceptions
 * @extends Error
 */
class TimeoutError extends Error {
  constructor() {
    super(message);
    this.name = "TimeoutError";
    this.message = "Operation timed out";
    Object.setPrototypeOf(this, TimeoutError.prototype);
  }

  /**
   * Checks if an error is a TimeoutError
   * @param error - The error to check
   */
  static isTimeoutError(error: unknown): error is TimeoutError {
    return error instanceof TimeoutError;
  }
}

/**
 * Configuration options for timeout behavior
 */
interface TimeoutOptions {
  /** Timeout duration in milliseconds */
  timeoutMs: number;
  /** Whether the operation supports abortion */
  abortable?: boolean;
  /** Optional callback to execute on timeout */
  onTimeout?: () => void;
}

/**
 * Wraps a promise with a timeout mechanism
 * @template T - The type of the promise result
 * @param promise - The promise to wrap
 * @param options - Timeout configuration options
 * @returns A promise that will reject if the timeout is exceeded
 * @throws {TimeoutError} When the operation times out
 *
 * @example
 * // Basic usage
 * const result = await withTimeout(
 *   fetch('https://api.example.com/data'),
 *   { timeoutMs: 5000 }
 * );
 *
 * // With abort signal
 * const result = await withTimeout(
 *   fetch('https://api.example.com/data'),
 *   { timeoutMs: 5000, abortable: true }
 * );
 */
async function withTimeout<T>(
  promise: Promise<T>,
  options: TimeoutOptions
): Promise<T> {
  const { timeoutMs, abortable = false, onTimeout } = options;

  if (timeoutMs <= 0) {
    throw new Error("Timeout must be greater than 0");
  }

  if (abortable) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => {
      controller.abort();
      onTimeout?.();
    }, timeoutMs);

    try {
      const result = await promise;
      clearTimeout(timeoutId);
      return result;
    } catch (error) {
      clearTimeout(timeoutId);
      if (error instanceof DOMException && error.name === "AbortError") {
        throw new TimeoutError();
      }
      throw error;
    }
  }

  return Promise.race([
    promise,
    new Promise<never>((_, reject) => {
      const timeoutId = setTimeout(() => {
        onTimeout?.();
        reject(new TimeoutError());
      }, timeoutMs);
    }),
  ]);
}

Let's look at a practical example of how to use the timeout pattern in real-world scenario:

async function fetchUserData(userId: string) {
  try {
    const response = await withTimeout(fetch(`/api/users/${userId}`), {
      timeoutMs: 5000,
      abortable: true,
      onTimeout: () => {
        // Log timeout event to monitoring system
        logger.warn(`User fetch timeout: ${userId}`);
      },
    });
    return await response.json();
  } catch (error) {
    if (TimeoutError.isTimeoutError(error)) {
      // Handle timeout specifically
      return fallbackUserData(userId);
    }
    throw error;
  }
}

In this example, we're fetching user data from an API with a 5-second timeout. If the operation exceeds the timeout, we log a warning. If the error is a timeout error, we fall back to a default user data.

Let's add some unit tests to ensure our implementation works as expected:

import { withTimeout, TimeoutError } from "./timeouts";

describe("withTimeout", () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });

  afterEach(() => {
    jest.useRealTimers();
  });

  test("should resolve when promise completes before timeout", async () => {
    const promise = Promise.resolve(42);
    const result = await withTimeout(promise, { timeoutMs: 1000 });
    expect(result).toBe(42);
  });

  test("should throw TimeoutError when promise exceeds timeout", async () => {
    const slowPromise = new Promise((resolve) => setTimeout(resolve, 2000));
    const timeoutPromise = withTimeout(slowPromise, { timeoutMs: 1000 });

    jest.advanceTimersByTime(1001);
    await expect(timeoutPromise).rejects.toThrow(TimeoutError);
  });

  test("should call onTimeout callback when timeout occurs", async () => {
    const onTimeout = jest.fn();
    const slowPromise = new Promise((resolve) => setTimeout(resolve, 2000));
    const timeoutPromise = withTimeout(slowPromise, {
      timeoutMs: 1000,
      onTimeout,
    });

    jest.advanceTimersByTime(1001);
    await expect(timeoutPromise).rejects.toThrow(TimeoutError);
    expect(onTimeout).toHaveBeenCalled();
  });

  test("should handle invalid timeout values", async () => {
    const promise = Promise.resolve(42);
    await expect(withTimeout(promise, { timeoutMs: -1 })).rejects.toThrow(
      "Timeout must be greater than 0"
    );
  });
});

Considerations and Best Practices

Choosing Timeout Values

Choosing appropriate timeout values is crucial for the effectiveness of the timeout pattern. Here are some key considerations:

  • Consider the Nature of the Operation: Different operations have different expected completion times. For example, a simple database query might complete in milliseconds, while a complex data processing task might take several seconds. Set timeout values based on the typical duration of the operation to avoid premature termination or excessive waiting.
  • Account for Network Latency in Distributed Systems: In distributed systems, network latency can significantly impact the time it takes for operations to complete. When setting timeout values, consider the potential delays caused by network communication between services. This is especially important for operations that involve multiple network hops or cross-region communication.
  • Use Configuration Rather than Hard-Coded Values: Hard-coding timeout values in your code can make it difficult to adjust them as needed. Instead, use configuration files or environment variables to define timeout values. This approach allows you to easily update timeout settings without modifying the code, making your application more flexible and maintainable.
  • Consider Using Different Timeouts for Different Environments: Different environments (e.g., development, testing, production) may have different performance characteristics and requirements. For example, you might want shorter timeouts in a development environment to quickly identify issues, while longer timeouts might be more appropriate in a production environment to account for real-world conditions. Use environment-specific configurations to set appropriate timeout values for each environment.

By considering these factors, you can choose timeout values that enhance the resilience and responsiveness of your application, ensuring it can handle various scenarios effectively.

Error Handling

Proper error handling is essential when implementing the timeout pattern to ensure your application remains robust and user-friendly. Here are some key considerations:

  • Always Catch Timeout Errors Specifically: When an operation times out, it's important to catch and handle the timeout error specifically. This allows you to differentiate between different types of errors and take appropriate actions. For example, you might want to log timeout errors differently or provide specific feedback to the user.
  • Consider Retry Strategies for Transient Failures: Transient failures are temporary issues that can often be resolved by retrying the operation. When an operation times out, consider implementing a retry strategy to attempt the operation again after a short delay. This can help mitigate issues caused by temporary network glitches or service disruptions.
  • Have Fallback Mechanisms Ready: When an operation fails due to a timeout, having fallback mechanisms in place can help maintain the functionality of your application. A fallback mechanism provides an alternative action or response when the primary operation fails. This can include returning cached data, calling a secondary service, or providing a default value as we saw in the fallback pattern.

By implementing these error handling strategies, you can ensure that your application remains resilient and provides a better user experience even when operations fail due to timeouts.

Monitoring and Observability

Effective monitoring and observability are crucial for understanding how timeouts affect your application and for identifying potential issues. Here are some key considerations:

  • Log Timeout Occurrences: Logging timeout occurrences helps you keep track of when and where timeouts happen in your application. This information is valuable for diagnosing issues and understanding the conditions that lead to timeouts. Ensure that your logs include relevant details such as the operation that timed out, the duration, and any error messages.
  • Track Timeout Patterns and Frequencies: Tracking the patterns and frequencies of timeouts can help you identify recurring issues and trends. By analyzing this data, you can determine if certain operations are more prone to timeouts and take proactive measures to address the underlying causes.
  • Set Up Alerts for Unusual Timeout Patterns: Setting up alerts for unusual timeout patterns ensures that you are promptly notified of potential issues. For example, if the number of timeouts exceeds a certain threshold within a specific period, an alert can be triggered. This allows you to investigate and address the issue before it impacts the user experience.
  • Monitor the Impact on System Resources: Monitoring the impact of timeouts on system resources helps you understand how timeouts affect the overall performance and stability of your application. For example, frequent timeouts might indicate resource contention or bottlenecks that need to be addressed. Use monitoring tools to track metrics such as CPU usage, memory consumption, and network latency.

By implementing these monitoring and observability practices, you can gain valuable insights into how timeouts affect your application and take proactive measures to ensure its resilience and performance.

Conclusion

Timeout patterns are a powerful tool for building resilient TypeScript applications that can gracefully handle failures and maintain responsiveness under challenging conditions. By setting appropriate timeout values, handling errors effectively, and monitoring timeout occurrences, you can enhance the reliability and performance of your application.