p-timeout
p-timeout takes a promise and gives you back a decorated promise that rejects with a TimeoutError if the original has not settled within a given number of milliseconds. You can swap the rejection for a fallback function, a custom error, or a plain resolve with undefined, and you can abort the wrapper early with an AbortSignal. It is about 700 bytes gzipped with zero dependencies. One thing it explicitly does not do: it never cancels the work you wrapped. The underlying promise keeps running, keeps holding memory, and keeps its side effects. p-timeout only stops you waiting for it. Its 45 million weekly downloads come almost entirely from being a transitive dependency of build tools and test runners rather than from people installing it directly.
Well built and tiny, but for most new code AbortSignal.timeout() does the same job with no dependency and actually stops the work. Reach for p-timeout when you are bounding a promise from an API that gives you no cancellation hook, and accept that the operation keeps running behind your back.
Use it if
- You need a deadline on a promise from an API that accepts no AbortSignal, such as an older database driver, a callback wrapped with promisify, or a third-party SDK method
- You want a fallback on timeout rather than a rejection: options.fallback runs your function (retry, cached value, degraded response) instead of throwing
- You need the timer itself to be controllable, either cleared early with the returned promise's .clear() method or swapped out via customTimers so sinon.useFakeTimers() does not freeze your production timeout
- You want a typed TimeoutError class you can instanceof against and subclass, instead of matching on error message strings
- The thing you are timing out already accepts an AbortSignal: fetch, node:fs/promises, undici, most modern SDKs. AbortSignal.timeout(ms) is built into Node 18+ and every current browser, needs no dependency, and actually tells the callee to stop working. The README says this too, in a note at the very top
- You expected the timeout to cancel the operation. It does not. A wrapped 30 second database query still runs for 30 seconds and still commits whatever it was going to commit; you just stopped listening at 5 seconds
- You are on CommonJS. Versions 5 and up are ESM only, so require('p-timeout') throws ERR_REQUIRE_ESM outside of Node's newer require-of-ESM support, and v7 additionally demands Node 20 or newer
- You are adding it to a codebase that already imports p-retry, p-queue or got: all of them have their own timeout options, and a second timeout layer on top mostly produces confusing double failures
- You need this in a browser bundle where every dependency is audited. Writing Promise.race against a setTimeout is roughly six lines and removes a supply chain entry
Setup reality
npm install p-timeout, no peer dependencies, no build step, TypeScript types included. The friction is module format and timers. Since v5 the package is ESM only with an exports map, so CommonJS projects and older Jest setups need either transformIgnorePatterns tweaks or a dynamic import(); v7 also sets engines.node to >=20, which quietly breaks CI runners still on Node 18. If your test suite calls sinon.useFakeTimers() or jest.useFakeTimers(), the internal setTimeout gets frozen and your timeout never fires, so you have to capture the real timer functions before faking and pass them through options.customTimers. Finally, remember to call .clear() on the returned promise in long-lived processes, otherwise the pending timer keeps the event loop alive until it expires.
Patterns
Reject a promise after a deadlinebasic-timeout
import pTimeout from 'p-timeout';
try {
const result = await pTimeout(slowOperation(), {milliseconds: 5000});
console.log(result);
} catch (error) {
console.error(error.message);
//=> 'Promise timed out after 5000 milliseconds'
}slowOperation() keeps running after the rejection; p-timeout only stops your await, it does not cancel anything.
Tell a timeout apart from a real failurecatch-timeout-error
import pTimeout, {TimeoutError} from 'p-timeout';
try {
await pTimeout(fetchReport(), {milliseconds: 2000});
} catch (error) {
if (error instanceof TimeoutError) {
return {status: 'degraded'};
}
throw error;
}Always branch on instanceof TimeoutError rather than the message string, which is user-configurable.
Set your own timeout message or errorcustom-message
await pTimeout(uploadFile(), {
milliseconds: 10_000,
message: 'Upload did not finish in 10s',
});
// or throw a specific error instance
class UploadTimeout extends TimeoutError {
name = 'UploadTimeout';
}
await pTimeout(uploadFile(), {
milliseconds: 10_000,
message: new UploadTimeout('upload stalled'),
});Subclass TimeoutError rather than Error for custom types, so callers checking instanceof TimeoutError still match.
Resolve with undefined instead of throwingresolve-instead-of-reject
const banner = await pTimeout(fetchBanner(), {
milliseconds: 300,
message: false,
});
if (banner === undefined) {
// render without the banner
}message: false is the only way to get a non-throwing timeout; the value is always undefined, so you cannot distinguish it from a genuine undefined result.
Run a fallback when the deadline passesfallback-on-timeout
const data = await pTimeout(fetchFromPrimary(), {
milliseconds: 1000,
fallback: () => fetchFromReplica(),
});fallback may return a value or a promise; if the fallback itself hangs it has no deadline of its own, so wrap it in another pTimeout when that matters.
Clear the timer so it stops holding the event loop openclear-pending-timer
const promise = pTimeout(job(), {milliseconds: 60_000});
process.on('SIGTERM', () => {
promise.clear();
});
await promise;A pending 60 second timer keeps a Node process alive even after the wrapped promise settles early; .clear() releases it.
Abort the wait early with an AbortSignalabort-the-wrapper
import pTimeout from 'p-timeout';
const controller = new AbortController();
document.querySelector('#cancel')
.addEventListener('click', () => controller.abort());
await pTimeout(longTask(), {
milliseconds: 30_000,
signal: controller.signal,
});The signal aborts your wait, not longTask(). To actually stop the work, the task has to accept the signal itself.
Turn the timeout off with a config valuedisable-timeout
const timeoutMs = config.timeoutMs ?? Number.POSITIVE_INFINITY;
await pTimeout(job(), {milliseconds: timeoutMs});Passing Infinity means never time out, which is handy for a single code path that has to support an opt-out.
Survive fake timers in testsfake-timers
import pTimeout from 'p-timeout';
import sinon from 'sinon';
const realSetTimeout = setTimeout;
const realClearTimeout = clearTimeout;
sinon.useFakeTimers();
await pTimeout(doSomething(), {
milliseconds: 2000,
customTimers: {setTimeout: realSetTimeout, clearTimeout: realClearTimeout},
});Capture the real timer functions before installing fake timers, otherwise the internal setTimeout is frozen and the timeout never fires.
Skip the dependency when the callee accepts a signalnative-abortsignal-timeout
// No p-timeout needed: fetch aborts the actual request.
const response = await fetch('/api/report', {
signal: AbortSignal.timeout(5000),
});
// Combining a deadline with a manual cancel:
const controller = new AbortController();
const signal = AbortSignal.any([
controller.signal,
AbortSignal.timeout(5000),
]);AbortSignal.timeout and AbortSignal.any are built into Node 20 and current browsers; the aborted call actually stops and frees its resources.
Import it from a CommonJS fileesm-only-import
// CJS file: a static require() throws ERR_REQUIRE_ESM on older Node.
async function withDeadline(promise, ms) {
const {default: pTimeout} = await import('p-timeout');
return pTimeout(promise, {milliseconds: ms});
}
module.exports = {withDeadline};v5 and later are ESM only; dynamic import is the portable escape hatch when you cannot convert the file.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| abort-utils | npm | You want native AbortSignal semantics and need to merge a timeout signal with a user-triggered cancel button |
| p-retry | npm | The real problem is a flaky operation that should be retried with backoff, not one that should fail after a fixed deadline |
| promise-timeout | npm | You are stuck on CommonJS and cannot consume an ESM-only package |