Deadline-based timeouts for async Node.js code with AbortSignal support.
Promise-based deadline enforcement for async code in Node.js. safe-timeouts helps you apply a single execution deadline across async functions, services, and external calls using standard AbortSignal semantics.
In real backend systems, timeouts are end-to-end, not per-function:
- An HTTP request has a deadline
- That deadline must apply across DB calls, service logic, and external APIs
- Nested functions should not accidentally extend the available time
Most timeout utilities fail here because they:
- don’t propagate context
- don’t compose across nested calls
- don’t integrate with
AbortSignal
safe-timeouts solves this correctly.
npm install safe-timeoutsNode.js >= 16 is required.
import { withTimeout, TimeoutError, safeAxios } from "safe-timeouts";
import axios from "axios";
try {
const resultWithSafeAxios = await withTimeout(2000, async () => {
const res = await safeAxios.get("https://api.example.com/users"); // no signal to be passed.
return res.data;
});
const resultWithAxios = await withTimeout(2000, async (signal) => { // signal to be taken.
const res = await axios.get("https://api.example.com/users", {signal}); // signal to be passed.
return res.data;
});
} catch (err) {
if (err instanceof TimeoutError) {
console.error("Request timed out");
}
}What happens:
-
A 2s deadline is created
-
An
AbortControlleris started internally -
If the deadline is exceeded:
- the promise rejects with
TimeoutError - the
AbortSignalis aborted - Axios cancels the HTTP request
- the promise rejects with
Deadlines propagate and compose automatically.
await withTimeout(3000, async () => {
await serviceA(); // uses part of the budget
await withTimeout(5000, async () => {
await serviceB(); // still limited by the original 3s
});
});The inner timeout cannot extend the outer deadline.
This makes time budgets safe and deterministic.
safeAxios is a convenience wrapper around Axios that automatically integrates with safe-timeouts.
When used inside withTimeout, HTTP requests are automatically cancellable. When used outside withTimeout, it behaves exactly like a normal Axios instance.
Example
import { withTimeout, safeAxios } from "safe-timeouts";
await withTimeout(2000, async () => {
const res = await safeAxios.get("/users");
return res.data;
});import { withTimeout, createSafeAxios } from "safe-timeouts";
const api = createSafeAxios({
baseURL: "https://api.example.com",
});
await withTimeout(1000, async () => {
await api.post("/sync");
});safe-timeouts uses AsyncLocalStorage to propagate timeout context across async boundaries.
Example flow
await withTimeout(2000, async () => {
await controller();
});
async function controller() {
return serviceA();
}
async function serviceA() {
return serviceB();
}
async function serviceB() {
return safeAxios.get("/users");
}
Context flow diagram
withTimeout
└─ Async context (deadline + AbortController)
├─ controller()
│ └─ serviceA()
│ └─ serviceB()
│ └─ safeAxios.get()
│ └─ axios(request + signal)The timeout context is created once Node automatically propagates it across async calls safeAxios reads the context at request time When the deadline expires, the request is aborted
import axios from "axios";
await withTimeout(2000, async (signal) => {
await controller(signal);
});
async function controller(signal) {
await serviceA(signal);
}
async function serviceA(signal) {
await serviceB(signal);
}
async function serviceB(signal) {
const res = await axios.get("/users", { signal });
return res.data;
}All functions share the same deadline by passing the same AbortSignal down the call chain.
These stop execution as soon as the deadline is exceeded:
fetch(Node 18+)axios(with{ signal })fs/promises(partial)stream.pipelinetimers/promises
Example:
// GET
await safeAxios.get(url); // 👈 No AbortSignal needed
// POST
await safeAxios.post(
url,
{ name: "Aryan", role: "admin" },
{
// 👈 No AbortSignal goes here
headers: {
"Content-Type": "application/json",
Authorization: "Bearer YOUR_TOKEN",
},
})
// GET
await axios.get(url, { signal }); // 👈 AbortSignal goes here
// POST
await axios.post(
url,
{ name: "Aryan", role: "admin" },
{
signal, // 👈 AbortSignal goes here
headers: {
"Content-Type": "application/json",
Authorization: "Bearer YOUR_TOKEN",
},
})These cannot be forcibly stopped:
setTimeout/ sleep- Sequelize queries
- CPU-bound loops
- legacy libraries
For these, safe-timeouts:
- stops waiting
- rejects the outer promise
- allows you to guard further logic
JavaScript cannot forcibly stop non-abort-aware operations (like setTimeout, Sequelize queries, or CPU-bound work).
When such operations exceed the deadline:
safe-timeoutsrejects the outer promise- abort-aware APIs are cancelled automatically
- JavaScript execution resumes only when the pending operation completes
To keep control flow predictable:
- prefer calling abort-aware APIs (Axios, fetch, streams) after non-abort-aware work
- abort-aware APIs will throw immediately if the deadline has already been exceeded
This design avoids hidden global checks while remaining honest about JavaScript limitations.
safe-timeouts works with Axios by passing the provided AbortSignal to the request.
import axios from "axios";
import { withTimeout } from "safe-timeouts";
await withTimeout(2000, async (signal) => {
const res = await axios.get("/users", { signal });
return res.data;
});Axios is abort-aware:
- if the deadline is exceeded before the request starts, Axios throws immediately
- if the deadline is exceeded while the request is in flight, Axios cancels the request
This explicit integration keeps cancellation predictable and avoids hidden behavior.
It is important to be explicit about limitations:
- ❌ It cannot forcibly stop JavaScript execution
- ❌ It cannot cancel non-abort-aware libraries
- ❌ It cannot stop CPU-bound loops
- ❌ It does not replace DB-level timeouts
This matches the realities of Node.js and modern async runtimes.
| Feature | setTimeout | safe-timeouts |
|---|---|---|
| End-to-end deadline | ❌ | ✅ |
| Nested composition | ❌ | ✅ |
| AbortSignal support | ❌ | ✅ |
| Context propagation | ❌ | ✅ |
| Concurrency-safe | ❌ | ✅ |
setTimeout works locally. safe-timeouts works across your entire async call graph.
Runs an async function with a deadline.
withTimeout<T>(ms: number, fn: (signal: AbortSignal) => Promise<T>): Promise<T>Rejects with TimeoutError when the deadline is exceeded.
Error thrown when the deadline is exceeded.
instanceof TimeoutError === trueUse safe-timeouts when:
- you want request-level deadlines
- you call multiple async services
- you rely on Axios, fetch, or streams
- you want correct nested timeout behavior
Do not use it as a replacement for DB-level query timeouts.
MIT