01
1. Define what the caller is waiting for
For this helper, success means an acceptable HTTP status and a fully parsed JSON body. Waiting for the headers alone is insufficient. Use a single time budget for both stages.
The example uses Node.js 24 built-in fetch and AbortSignal.timeout. Save it as get-json.mjs; it is an ES module exporting one helper rather than a running server.
02
2. Keep the signal through body consumption
Create the signal before fetch and await response.json inside the same try block. A body-read failure can use a different error name from a timeout before headers, so the wrapper checks whether its own signal aborted.
The application-specific error codes below are defined by this helper, not by Node.js. Other failures keep their original cause. Non-successful HTTP responses are identified explicitly instead of being treated as successful JSON results.
export async function getJson(url, timeoutMs = 1000) {
const signal = AbortSignal.timeout(timeoutMs);
try {
const response = await fetch(url, { signal });
if (!response.ok) {
await response.body?.cancel();
const error = new Error(`Upstream HTTP ${response.status}`);
error.code = 'UPSTREAM_HTTP';
throw error;
}
return await response.json();
} catch (cause) {
if (signal.aborted) {
const error = new Error('Upstream deadline exceeded', { cause });
error.code = 'UPSTREAM_TIMEOUT';
throw error;
}
throw cause;
}
}03
3. Run the local verification fixture
Save this second file as timeout-lab.mjs beside get-json.mjs, then run node timeout-lab.mjs. It starts a loopback-only fixture and checks the success, slow-header, slow-body, HTTP-error and malformed-JSON cases.
This fixture has no third-party dependencies and closes its sockets and timers after testing. Its deliberately broken response routes exist only for the test.
import assert from 'node:assert/strict';
import http from 'node:http';
import { once } from 'node:events';
import { getJson } from './get-json.mjs';
const checks=[];
const counts={}; const timers=[];
const server=http.createServer((req,res)=>{
counts[req.url]=(counts[req.url]||0)+1;
if(req.url==='/headers'){timers.push(setTimeout(()=>res.end('{}'),600));return;}
if(req.url==='/body'){res.writeHead(200,{'Content-Type':'application/json'});res.write('{');timers.push(setTimeout(()=>res.end('"ok":true}'),600));return;}
if(req.url==='/error'){res.writeHead(503);res.end('unavailable');return;}
if(req.url==='/invalid'){res.end('not json');return;}
res.setHeader('Content-Type','application/json');res.end('{"ok":true}');
});
server.listen(0,'127.0.0.1');await once(server,'listening');const base='http://127.0.0.1:'+server.address().port;
try {
assert.deepEqual(await getJson(base+'/ok',1000),{ok:true});checks.push({case:'fetch-success',passed:true});
for(const route of ['headers','body']){
const start=Date.now();await assert.rejects(getJson(base+'/'+route,150),{code:'UPSTREAM_TIMEOUT'});
const ms=Date.now()-start;assert.ok(ms<550);checks.push({case:'fetch-'+route+'-deadline',passed:true,elapsedMs:ms});
}
await assert.rejects(getJson(base+'/error',1000),{code:'UPSTREAM_HTTP'});assert.equal(counts['/error'],1);checks.push({case:'fetch-http-status-no-retry',passed:true});
await assert.rejects(getJson(base+'/invalid',1000),SyntaxError);checks.push({case:'fetch-invalid-json',passed:true});
} finally {for(const timer of timers)clearTimeout(timer);server.closeAllConnections();await new Promise(resolve=>server.close(resolve));}
console.log(JSON.stringify(checks, null, 2));04
4. Use a caller-specific budget
Import getJson and supply a budget appropriate to the user-facing operation. Await it in the caller’s normal error handling. Log a useful operation identifier and the helper’s error code; do not log sensitive authorization headers to diagnose latency.
AbortSignal.timeout takes milliseconds. Choose an overall request budget before adding retries so repeated attempts cannot silently consume several times the latency you intended.
05
5. Test headers and body separately
Use a controlled upstream with one route that delays all headers and another that sends headers plus an incomplete JSON body. Both must fail within the chosen budget. Also test a fast valid response, HTTP 503 and malformed JSON.
The success case should return parsed data. HTTP 503 should produce UPSTREAM_HTTP without an automatic retry. Malformed JSON should remain a parse failure, while either stalled path should produce UPSTREAM_TIMEOUT. These are different operational problems.
06
6. Do not turn a timeout into a duplicate write
A client abort does not prove the remote server rolled back its work. Before retrying a write, use the remote API’s idempotency mechanism or reconciliation process. The helper intentionally performs one attempt.
For untrusted or large responses, add a separate streaming size limit and input validation. A time budget alone does not cap memory consumption. Also consider propagation of caller cancellation when integrating this helper into a larger request lifecycle.
07
Verification boundary
A local HTTP fixture exercised all five outcomes on Node.js 24. Both a delayed-header route and a stalled-body route hit the deadline, a fast response parsed, HTTP 503 stayed distinct and malformed JSON remained an error. The fixture confirmed that the HTTP-error route was called once.
Know the limits
This Node.js 24 helper handles small trusted JSON responses. It does not impose a response-size limit or decide whether a timed-out remote operation took effect.
Sources & further reading
A technical publication by CoreTrailKit.
Browse all articles