DeesseJS FP

Timeout

Time-bound operations with automatic AbortSignal support

The timeout() utility wraps async operations with a time limit, returning Err<TimeoutError> if the deadline is exceeded.

Why Timeout?

Async operations can hang forever: network partitions, deadlocks, broken external services. Without timeout, a single stalled operation can block your entire application.

Timeout provides:

  • Resource protection -- Prevent resource leaks from hanging connections
  • Graceful degradation -- Return a typed error instead of hanging indefinitely
  • Composability -- Works with Result.gen and all Result operations
  • Cancellation integration -- Respects AbortSignal for coordinated cancellation

Timeout with Static Method

You wrap an async operation with a deadline using the static Result.timeout() method. This method accepts your async operation and a configuration object that specifies the time limit and error handling behavior. The operation runs until either it completes successfully or the deadline is reached, at which point the timeout error is returned.

Here is a complete example showing how to define a timeout error and wrap a user fetch operation with a 5-second deadline:

timeout-basic.ts
import { Result, error } from '@deessejs/fp';
import { z } from 'zod';

const TimeoutError = error({
  name: 'TimeoutError',
  schema: z.object({
    deadline: z.number(),
    elapsed: z.number(),
    operation: z.string()
  })
});

// Basic usage - reject after 5000ms
const result = await Result.timeout(
  fetchUser(id),
  { deadline: 5000, timeout: TimeoutError }
);

TimeoutOptions

The timeout configuration is defined by the TimeoutOptions interface. Each parameter controls a different aspect of the timeout behavior, from the time boundary to error handling and cancellation semantics.

This interface defines all available configuration options for the timeout function:

timeout-options.ts
interface TimeoutOptions<T, E = Error> {
  /** Maximum time in milliseconds (> 0) */
  deadline: number;

  /** Constructor for TimeoutError */
  timeout?: ErrorConstructor | ((info: TimeoutInfo) => TaggedError);

  /** External abort signal to respect */
  abortSignal?: AbortSignal;

  /** Fallback on timeout (returns AsyncResult) */
  onTimeout?: () => AsyncResult<T, E>;

  /** What to do with underlying op after timeout: 'abort' (default) or 'continue' */
  resume?: 'abort' | 'continue';

  /** Name for error messages */
  operationName?: string;
}

interface TimeoutInfo {
  deadline: number;
  elapsed: number;
  operation: string;
}

Parameter details:

  • deadline (required): The maximum time in milliseconds that the operation is allowed to run. Must be a positive number. When the deadline is exceeded, the timeout error is returned.

  • timeout (optional): The error constructor or factory function that creates the timeout error. If not provided, a default timeout error is used. You can pass either a standard Error constructor or a tagged error created with the error() function.

  • abortSignal (optional): An external AbortSignal that the timeout should respect. If the signal is aborted before the operation completes, an AbortError is returned instead of a TimeoutError.

  • onTimeout (optional): A fallback function that returns an alternative result when the timeout occurs. This allows you to provide cached data, default values, or alternative operations without receiving an error.

  • resume (optional): Controls what happens to the underlying operation after a timeout. When set to 'abort' (the default), an internal AbortSignal is used to signal the operation to stop. When set to 'continue', the operation continues running in the background and you are responsible for tracking and cleaning it up.

  • operationName (optional): A human-readable name for the operation that appears in error messages, making it easier to identify which operation timed out when debugging.

Behavior

Understanding how timeout behaves in different scenarios helps you design robust systems.

Deadline Boundary Values

The deadline parameter accepts special values that change the behavior:

ValueBehavior
deadline <= 0Returns Err<TimeoutError> immediately with elapsed: 0
deadline === InfinityNo timeout applied, behaves like plain operation
deadline > 0Normal timeout behavior

Success Path

When the operation completes before the deadline is reached, the original Result from the operation is returned unchanged. This means if the operation succeeds, you get Ok<T>, and if it fails, you get Err<E> with whatever error the operation produced.

Timeout Path

When the deadline is exceeded, the timeout returns Err<TimeoutError> immediately. The behavior of the underlying operation depends on the resume option:

  • If resume: 'abort' (the default), an internal AbortSignal is used to abandon the operation. The operation may or may not actually stop, depending on whether it supports AbortSignal.

  • If resume: 'continue', the underlying operation keeps running. You must track and clean up these orphaned operations yourself.

Abort Path

When the external abort signal is triggered before the operation completes, the timeout returns Err<AbortError>. If the signal is already aborted when the timeout starts, the error is returned immediately.

Timeout with Fallback

Sometimes you want to provide an alternative result when a timeout occurs instead of returning an error. You can achieve this by passing an onTimeout callback that returns an alternative Result.

This pattern is useful when you have cached data available or want to return a default value rather than propagating the timeout error. The fallback function is only called when the deadline is exceeded, not when the operation succeeds or fails normally.

In this example, if the heavy query times out after 5 seconds, the cached data is returned instead of a TimeoutError:

timeout-fallback.ts
const result = await Result.timeout(
  fetchHeavyQuery(),
  {
    deadline: 5000,
    timeout: TimeoutError,
    onTimeout: () => Result.ok(cachedData)
  }
);
// Returns cached data instead of TimeoutError

External AbortSignal

You can combine timeout with an external AbortController to enable coordinated cancellation from anywhere in your application. This is particularly useful when you need to cancel operations due to user navigation, component unmounting, or other application-level events.

When you pass an AbortSignal to the timeout, the timeout monitors both the signal and the deadline. If the signal is aborted first, you receive an AbortError. If the deadline is reached first (and the signal is not aborted), you receive a TimeoutError.

This example sets up an abort controller that cancels the operation after 3 seconds, while the timeout deadline is set to 5 seconds. If the abort happens first, you get an AbortError:

timeout-abort-signal.ts
const controller = new AbortController();

// External cancellation
setTimeout(() => controller.abort(), 3000);

const result = await Result.timeout(
  fetchUser(id),
  {
    deadline: 5000,
    timeout: TimeoutError,
    abortSignal: controller.signal
  }
);
// Err<AbortError> if aborted before completion
// Err<TimeoutError> if deadline exceeded before abort

Resource Cleanup

Different applications have different requirements for what happens to operations that time out. The resume option gives you control over this behavior.

Resume Abort (default)

When you set resume: 'abort', the timeout uses an internal AbortSignal to signal the operation to stop. This is the default behavior because it allows resources to be reclaimed quickly. However, there is no guarantee that the operation will actually stop, since not all operations respect AbortSignal. The memory and resources of the abandoned operation are eventually reclaimed when it settles.

Resume Continue

When you set resume: 'continue', the underlying operation continues running after the timeout returns. You are responsible for tracking these orphaned operations and cleaning them up. Use this option when your operation does not support AbortSignal but must complete for correctness, such as when writing to a database or completing a file transfer.

Error Types

The timeout function returns different error types depending on what went wrong.

TimeoutError

The TimeoutError is returned when the deadline is exceeded. It contains information about the timeout threshold, how much time actually elapsed, and the name of the operation that timed out. You define this error using the error() function from the library.

This is the schema definition for the TimeoutError:

timeout-error-definition.ts
const TimeoutError = error({
  name: 'TimeoutError',
  schema: z.object({
    deadline: z.number(),
    elapsed: z.number(),
    operation: z.string()
  })
});
PropertyTypeDescription
_tag"TimeoutError"Discriminator
deadlinenumberThe timeout threshold in ms
elapsednumberActual time before timeout in ms
operationstringName of the timed-out operation

AbortError

The AbortError is returned when the external abort signal is triggered before the operation completes. The reason property optionally contains whatever value was passed to the abort call.

This is the schema definition for the AbortError:

abort-error-definition.ts
const AbortError = error({
  name: 'AbortError',
  schema: z.object({
    reason: z.unknown().optional()
  })
});
PropertyTypeDescription
_tag"AbortError"Discriminator
reasonunknownThe abort reason if provided

Using with Retry

Timeout integrates well with the retry system. When you configure retry with a maxTotalTime option, it uses the timeout internally to enforce a global deadline across all retry attempts. This ensures that no matter how many retries occur, the entire retry operation completes within your specified time.

You can also use timeout with retry to distinguish between transient errors and timeout errors, so you can decide whether to retry based on the type of failure:

timeout-retry.ts
// Retry with deadline (retry stops when deadline reached)
const result = await Result.retry(
  fetchUser(id),
  {
    attempts: 3,
    maxTotalTime: 10000,  // Global timeout - uses timeout internally
    shouldRetry: (err) => err._tag !== 'TimeoutError'
  }
);

Complete Example

This example demonstrates a real-world pattern: fetching data from a network endpoint with timeout protection, handling network errors, and falling back to cached data on timeout.

You define custom errors for network and timeout scenarios, then create a function that wraps the fetch operation with timeout. The function attempts to fetch and parse JSON, returns a network error if that fails, and applies a timeout deadline to prevent hanging. When a timeout occurs, an optional fallback returns cached data.

complete-example.ts
import { Result, error } from '@deessejs/fp';
import { z } from 'zod';

const NetworkError = error({
  name: 'NetworkError',
  schema: z.object({ url: z.string(), cause: z.unknown().optional() })
});

const TimeoutError = error({
  name: 'TimeoutError',
  schema: z.object({
    deadline: z.number(),
    elapsed: z.number(),
    operation: z.string()
  })
});

// Fetch with timeout
async function fetchWithTimeout(url: string, deadline: number) {
  return Result.timeout(
    Result.attemptAsync(
      () => fetch(url).then(r => r.json()),
      (cause) => NetworkError({ url, cause })
    ),
    {
      deadline,
      timeout: TimeoutError,
      operationName: `fetch(${url})`
    }
  );
}

// With fallback
const result = await Result.timeout(
  fetchHeavyQuery(),
  {
    deadline: 2000,
    timeout: TimeoutError,
    onTimeout: () => Result.ok(getCachedData())
  }
);

See Also

On this page