{ ILoveJS }

Promise with timeout

typescript

A TypeScript utility function that wraps any promise and automatically rejects with a custom TimeoutError if the promise doesn't resolve within a specified time limit.

promisestimeoutasync

Code

typescript
class TimeoutError extends Error {
  readonly name = 'TimeoutError' as const;
  readonly timeoutMs: number;

  constructor(timeoutMs: number, message?: string) {
    super(message ?? `Operation timed out after ${timeoutMs}ms`);
    this.timeoutMs = timeoutMs;
    Object.setPrototypeOf(this, TimeoutError.prototype);
  }
}

interface WithTimeoutOptions {
  timeoutMs: number;
  errorMessage?: string;
}

async function withTimeout<T>(
  promise: Promise<T>,
  options: WithTimeoutOptions | number
): Promise<T> {
  const { timeoutMs, errorMessage } = typeof options === 'number'
    ? { timeoutMs: options, errorMessage: undefined }
    : options;

  if (timeoutMs <= 0) {
    throw new RangeError('Timeout must be a positive number');
  }

  let timeoutId: ReturnType<typeof setTimeout> | undefined;

  const timeoutPromise = new Promise<never>((_, reject) => {
    timeoutId = setTimeout(() => {
      reject(new TimeoutError(timeoutMs, errorMessage));
    }, timeoutMs);
  });

  try {
    return await Promise.race([promise, timeoutPromise]);
  } finally {
    if (timeoutId !== undefined) {
      clearTimeout(timeoutId);
    }
  }
}

function isTimeoutError(error: unknown): error is TimeoutError {
  return error instanceof TimeoutError;
}

// Example usage and demonstration
async function fetchWithDelay(delayMs: number): Promise<string> {
  return new Promise((resolve) => {
    setTimeout(() => resolve('Data fetched successfully'), delayMs);
  });
}

async function main() {
  console.log('Example 1: Promise resolves before timeout');
  try {
    const result = await withTimeout(fetchWithDelay(100), 500);
    console.log('Result:', result);
  } catch (error) {
    console.error('Error:', error);
  }

  console.log('\nExample 2: Promise times out');
  try {
    const result = await withTimeout(fetchWithDelay(1000), {
      timeoutMs: 200,
      errorMessage: 'API request took too long'
    });
    console.log('Result:', result);
  } catch (error) {
    if (isTimeoutError(error)) {
      console.log('Caught TimeoutError:', error.message);
      console.log('Timeout was:', error.timeoutMs, 'ms');
    } else {
      throw error;
    }
  }

  console.log('\nExample 3: Using with fetch (simulated)');
  const simulatedFetch = (): Promise<{ status: number }> => 
    new Promise((resolve) => setTimeout(() => resolve({ status: 200 }), 50));

  try {
    const response = await withTimeout(simulatedFetch(), 1000);
    console.log('Response status:', response.status);
  } catch (error) {
    if (isTimeoutError(error)) {
      console.log('Request timed out');
    }
  }
}

main();

export { withTimeout, TimeoutError, isTimeoutError, WithTimeoutOptions };

How It Works

This utility solves a common problem in asynchronous programming: preventing operations from hanging indefinitely. The core technique uses Promise.race() to pit the original promise against a timeout promise, returning whichever settles first. If the timeout wins, we reject with a custom TimeoutError that provides context about what happened.

The TimeoutError class extends the built-in Error with additional metadata. We store the timeout duration in the error object, which helps with debugging and allows calling code to make decisions based on the timeout value. The Object.setPrototypeOf call ensures instanceof checks work correctly even in environments with complex prototype chains or when transpiling to older JavaScript versions.

The withTimeout function accepts either a simple number for the timeout or an options object for more control. This overloaded interface keeps simple cases simple while allowing customization when needed. The generic type parameter T preserves the original promise's resolved type, ensuring full type safety throughout the call chain. Input validation rejects non-positive timeouts immediately with a descriptive RangeError.

A critical detail is the finally block that clears the timeout. Without this cleanup, the timeout's setTimeout would continue running even after the original promise resolves, potentially causing memory leaks in long-running applications or test suites. This pattern ensures we never leave dangling timers regardless of how the race concludes.

The type guard function isTimeoutError provides type-safe error handling in catch blocks. This is preferable to checking error.name === 'TimeoutError' because TypeScript can narrow the type and provide autocomplete for TimeoutError-specific properties. Use this utility for API calls, database queries, or any async operation where you need guaranteed response times. Avoid using it when the underlying operation has its own timeout mechanism, as competing timeouts can cause confusing behavior.