Does HTTP keep-alive make API calls faster?

Yes, by about 3x on HTTPS. At 380 ms RTT, a reused connection answered in 385 ms against 1,153 ms for a new one, measured with server-side connection counts. The catch is a stale-socket race that cost 14 to 19 errors per 1,000 requests until we fixed the idle timeout.

Does HTTP keep-alive make API calls faster?

Yes, by about 3x: at a 380 ms round trip, an HTTPS API call on a reused connection took a median 385 ms, and the same call on a new connection took 1,153 ms. Two hundred sequential calls took 77.7 seconds with HTTP keep-alive and 230.5 seconds without it. A new connection costs two extra round trips, one for TCP and one for TLS 1.3, and nothing you tune on the server can remove them.

The number that matters for integrations is not the ratio, which held near 3x from 1 ms to 380 ms. It is the absolute cost, which grows with distance: 6.6 ms per call on loopback, where nobody notices, and 767 ms per call from Vietnam to a server in Oregon, where it is most of the call. Reuse also has a failure mode that only exists when it is on, and it cost 14 to 19 errors per 1,000 requests in our runs until we changed one timeout.

The short answer

  • A new HTTPS connection cost two extra round trips per call. Measured medians for 200 sequential calls: 155 ms against 462 ms at 150 ms RTT, and 385 ms against 1,153 ms at 380 ms RTT. That is 3.0x, and 767 ms per call.
  • Node 23's defaults reuse connections. On the server, 200 sequential fetch() calls opened 2 TCP connections, and https.request with no agent option opened 1. agent: false and new https.Agent({ keepAlive: false }) opened 200.
  • curl in a shell loop never reuses. Twenty curl processes opened 20 connections and took 3,584 ms at 50 ms RTT. One curl with 20 URLs opened 1 and took 1,205 ms.
  • Reuse brings a race with the server's idle timeout. With the server's Keep-Alive hint removed, pauses near its idle close cost 14 errors per 1,000 requests with https.Agent (ECONNRESET) and 19 with fetch (other side closed).
  • The fix is an idle timeout at least one RTT below the server's, or one retry. A 1,500 ms client idle timeout gave 0 errors per 1,000. At 1,900 ms, below the server's close but inside the RTT margin, it still gave 4. Retrying once on a reset also gave 0.

What was measured, and on what

Apple M3, 8 cores, 16 GB RAM, macOS 26.4.1 (build 25E253). Node v23.5.0 ran the server, the proxy and the clients, using only node:https, node:net and the built-in fetch, which is Node's bundled undici. The certificate was a self-signed ECDSA P-256 certificate from OpenSSL 3.6.3. The HTTPS server answered every request with the same 200-byte JSON body.

Round-trip time came from a TCP proxy we wrote in Node. It delays every chunk by half the RTT in each direction and keeps the order. The client's first byte cannot reach the server before 1.5 RTT, standing in for the SYN / SYN-ACK exchange. We simulated RTTs of 1, 50, 150 and 380 ms. The 380 ms figure is not a guess: it is the real round trip from Vietnam to our server in Oregon, measured earlier and reused here as the setting.

Reuse was counted on the server, not assumed. The server counted connection and secureConnection events and TLS session resumptions. Other benchmarks were running on the same laptop, so treat the medians and ratios as the signal. The injected delay is hundreds of times larger than any CPU jitter.

A new HTTPS connection costs three round trips at 380 ms RTT, 1,153 ms, against one round trip, 385 ms, when reused

How much faster is keep-alive, by round-trip time?

Two hundred sequential GET requests at each RTT, run once per client mode:

RTT fetch() reused https.Agent keepAlive fetch(), server closes https.Agent no keepAlive Cost of a new connection
1 ms 3.0 ms (0.66 s) 3.4 ms (0.69 s) 9.6 ms (1.96 s) 9.1 ms (1.86 s) ~6 ms
50 ms 54.7 ms (11.1 s) 54.5 ms (11.0 s) 162.5 ms (32.5 s) 159.6 ms (32.0 s) ~107 ms
150 ms 155.6 ms (31.8 s) 154.7 ms (31.2 s) 462.2 ms (92.5 s) 462.7 ms (92.6 s) ~307 ms
380 ms 385.9 ms (78.7 s) 384.5 ms (77.7 s) 1,152.7 ms (230.5 s) 1,152.1 ms (230.4 s) ~767 ms

Each cell is the median per request, with the total for 200 in brackets. The last column is calculated: the no-reuse median minus the reuse median. Server counts confirm the modes did what their names say. The reused runs opened 1 or 2 connections for 200 requests, and the no-reuse runs opened 200.

What surprised us was how flat the ratio stays. It was 2.7x to 3.0x at every RTT, including 1 ms. On loopback the extra cost is CPU, mostly the TLS handshake, which costs about 0.2 ms of server CPU on its own, plus the proxy. Over a long link it is round trips. Both come out near 3x because a reused call is one round trip and a new call is three.

That flatness is why loopback benchmarks mislead. "3x faster" at 3 ms against 9 ms looks like noise. The same 3x at 380 ms means an integration that makes 10,000 calls to one vendor spends about 2.1 extra hours on handshakes. That figure is calculated from the 767 ms, not measured.

TLS session resumption did not help. new https.Agent({ keepAlive: false }) resumed 199 of 200 sessions and agent: false resumed none, and their medians at 50 ms were 159.6 and 160.3 ms. TLS 1.3 resumption skips the certificate, not the round trip.

Does Node's fetch reuse connections by default?

Yes. This is what each client actually did at 50 ms RTT over 200 sequential calls, counted from the server:

Client, as written TCP connections Median
fetch(url) 2 54.7 ms
fetch(url, { headers: { connection: 'close' } }) 198 162.8 ms
https.request(opts), no agent option 1 54.6 ms
https.request({ ...opts, agent: false }) 200 160.3 ms
new https.Agent({ keepAlive: false }) 200 159.6 ms
new https.Agent({ keepAlive: true }) 1 54.5 ms
curl in a shell loop, 20 calls (curl 8.7.1) 20 3,584 ms total
one curl with 20 URLs 1 1,205 ms total

Node's global agent printed its own options as keepAlive: true, scheduling: 'lifo', timeout: 5000, so a bare https.request reuses. The trap sits one step away. Once you build your own https.Agent to pass a CA, a proxy or maxSockets, the constructor's documented default is keepAlive: false, and every call pays the full handshake again. We did not measure axios. In Node it sends through whichever http.Agent it is given, so it behaves like the rows above. fetch opened 2 connections rather than 1 in every sequential run, and we did not trace why.

Does keep-alive help with concurrent requests?

Less, because the first wave has to open connections anyway. Fifty requests with 10 in flight at a time, 150 ms RTT:

Client Total Median TCP connections
fetch() default 1,299 ms 157.7 ms 15
fetch(), server closes 2,402 ms 477.6 ms 50
https.Agent keepAlive 1,114 ms 158.9 ms 10
https.Agent no keepAlive 2,398 ms 475.6 ms 50

Reuse cut the total by 1.8x to 2.2x. The median dropped 3x, as it did in sequence, but the first 10 requests paid for their handshakes. fetch opened 15 sockets for a concurrency of 10, and 17 in an earlier pass of the same test.

Why does keep-alive cause ECONNRESET?

A reused socket can die while it sits idle. The server closes it after its idle timeout, and the FIN takes half an RTT to reach you. A request sent in that window arrives at a closed socket. On a 150 ms link, that window is about one RTT wide, 150 ms, which is not rare.

We gave the server keepAliveTimeout: 1000, sent 1,000 requests in bursts of 10, and paused a random 1.5 to 2.5 seconds between bursts, at 150 ms RTT. The first thing we learned was Node's, not the client's. Node v23.5.0 closed the idle socket 2,003 ms after the response with keepAliveTimeout: 1000, and 6,003 ms after with 5000. It closes about a second late. Our first attempt paused 0.5 to 1.5 seconds and got 0 errors in 400, 600 and 800 requests, because no pause ever reached the real close.

Setup (150 ms RTT, server closes at ~2,003 ms) Errors per 1,000 Connections Time
Server sends Keep-Alive: timeout=1, https.Agent keepAlive 0 1,000 583 s
Server sends Keep-Alive: timeout=1, fetch() 0 1,000 584 s
No hint, https.Agent keepAlive 14 (ECONNRESET socket hang up) 64 365 s
No hint, fetch() 19 (UND_ERR_SOCKET other side closed) 163 388 s
No hint, https.Agent idle timeout: 1900 4 64 366 s
No hint, https.Agent idle timeout: 1500 0 100 375 s
No hint, https.Agent + one retry on reset 0 (13 retries) 64 367 s
No hint, fetch() + one retry on reset 0 (17 retries) 162 391 s

Three things are in that table.

The hint protects Node clients, by giving up reuse. Node's server sends Keep-Alive: timeout=1, and with it both clients opened a new connection for every one of the 1,000 requests. Nothing failed, and the run took 60% longer. The header was the only difference from the failing rows, so the clients are reading it. A server or load balancer that closes idle connections without the header gives your client nothing to read.

"Below the server's timeout" is not enough. A client idle timeout of 1,900 ms sits under the server's 2,003 ms and still failed 4 times. The client starts its idle clock when the response arrives, half an RTT after the server started its own, and its request needs another half RTT to land. The margin has to be at least one RTT. At 1,500 ms there were no errors. The window arithmetic is calculated. The 14, 4 and 0 are measured.

A retry works, and it was safe here for a reason you cannot count on. In every failed run, the server's request count fell short of 1,000 by exactly the number of errors, 986 for 14. None of the reset requests reached the handler. This is the one network error where that is usually true, but it is not guaranteed, so retry automatically only idempotent calls. Which errors to retry covers the POST case.

Our own check script hit this race on its first run. It paused 1.5 seconds between tests against this server, fetch reused the idle socket, and the script died with other side closed.

What should an integration do?

  • Keep reuse on. At any real distance it is worth two round trips per call.
  • If you construct an https.Agent, pass keepAlive: true explicitly, and set its timeout below the vendor's idle timeout by at least one RTT.
  • Retry once on ECONNRESET or UND_ERR_SOCKET for GETs and other idempotent calls. A retry also covers the servers whose timeout you do not know.
  • Set a timeout for the response itself as well. That default is five minutes, and keep-alive does not change it.

Check it yourself

One file. It generates its own certificate with openssl, starts an HTTPS server on 127.0.0.1:18180 and a latency proxy on 18181, and needs Node 18 or newer. It takes about five minutes at the default 150 ms RTT.

'use strict';
// node check.cjs  — HTTPS server + latency proxy + two clients, one file.
// Needs Node 18+ and the openssl CLI. RTT defaults to 150 ms: RTT=380 node check.cjs
const { execFileSync, spawnSync } = require('node:child_process');
const fs = require('node:fs'), os = require('node:os'), path = require('node:path');
const https = require('node:https'), net = require('node:net');

if (!process.env.KA_CERT_DIR) {                    // 1. make a cert, re-run with it trusted
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ka-check-'));
  execFileSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1',
    '-keyout', path.join(dir, 'key.pem'), '-out', path.join(dir, 'cert.pem'), '-days', '1', '-nodes',
    '-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1'], { stdio: 'ignore' });
  const r = spawnSync(process.execPath, [__filename], { stdio: 'inherit',
    env: { ...process.env, KA_CERT_DIR: dir, NODE_EXTRA_CA_CERTS: path.join(dir, 'cert.pem') } });
  fs.rmSync(dir, { recursive: true, force: true });
  process.exit(r.status);
}

const RTT = +(process.env.RTT || 150), SPORT = 18180, PPORT = 18181;
const key = fs.readFileSync(path.join(process.env.KA_CERT_DIR, 'key.pem'));
const cert = fs.readFileSync(path.join(process.env.KA_CERT_DIR, 'cert.pem'));
const conns = { n: 0 };

// 2. HTTPS server that counts TCP connections, idle timeout 1 s, no Keep-Alive hint
const server = https.createServer({ key, cert, keepAliveTimeout: 1000 }, (req, res) => {
  res.setHeader('connection', req.url === '/close' ? 'close' : 'keep-alive');
  res.end('{"ok":true}');
}).on('connection', () => conns.n++).listen(SPORT, '127.0.0.1');

// 3. proxy: delays each direction by RTT/2; the first byte cannot land before 1.5 RTT,
//    because the client only sends it once the SYN-ACK is back (the TCP handshake)
const proxy = net.createServer({ allowHalfOpen: true }, (c) => {
  const u = net.connect({ port: SPORT, host: '127.0.0.1', allowHalfOpen: true });
  const t0 = Date.now(); let upT = 0, downT = 0;
  const later = (dir, fn) => {
    const at = dir === 'up' ? (upT = Math.max(upT, Date.now() + RTT / 2, t0 + RTT * 1.5))
                            : (downT = Math.max(downT, Date.now() + RTT / 2));
    setTimeout(fn, at - Date.now());
  };
  const ok = (s) => !s.destroyed;
  c.on('data', (d) => later('up', () => ok(u) && u.write(d)));
  u.on('data', (d) => later('down', () => ok(c) && c.write(d)));
  c.on('end', () => later('up', () => ok(u) && u.end()));
  u.on('end', () => later('down', () => ok(c) && c.end()));
  c.on('error', () => later('up', () => ok(u) && u.resetAndDestroy()));
  u.on('error', () => later('down', () => ok(c) && c.resetAndDestroy()));
  c.on('close', () => later('up', () => ok(u) && u.destroy()));
  u.on('close', () => later('down', () => ok(c) && c.destroy()));
}).listen(PPORT, '127.0.0.1');

const get = (p, agent) => new Promise((ok, no) => {
  https.request({ host: '127.0.0.1', port: PPORT, path: p, agent, ca: cert }, (res) => {
    res.resume(); res.on('end', ok);
  }).on('error', no).end();
});
const viaFetch = (p) => fetch(`https://127.0.0.1:${PPORT}${p}`).then((r) => r.text());
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const median = (a) => a.sort((x, y) => x - y)[a.length >> 1];

async function run(label, call, n = 40) {
  const before = conns.n, lat = [], t0 = performance.now();
  for (let i = 0; i < n; i++) { const t = performance.now(); await call(); lat.push(performance.now() - t); }
  await sleep(200);
  console.log(`${label.padEnd(34)} total ${String(Math.round(performance.now() - t0 - 200)).padStart(6)} ms` +
    `  median ${median(lat).toFixed(0).padStart(4)} ms  connections ${conns.n - before}`);
  await sleep(3000);   // let idle sockets die first: a 1.5 s gap here raced the server and crashed
}

// Node's server actually closes an idle socket ~1 s AFTER keepAliveTimeout (2 s here),
// so the pauses that race it are the ones just under 2 s.
async function stale(label, call, n = 300) {       // bursts of 10, pauses 1.5-2.5 s
  let errors = 0, seed = 7;
  for (let i = 0; i < n; i++) {
    if (i && i % 10 === 0) { seed = (seed * 16807) % 2147483647; await sleep(1500 + (seed % 1000)); }
    try { await call(); } catch { errors++; }
  }
  console.log(`${label.padEnd(34)} errors ${errors}/${n}`);
}

(async () => {
  console.log(`node ${process.version}, simulated RTT ${RTT} ms, 40 sequential requests each`);
  await run('fetch, default', () => viaFetch('/'));
  await run('fetch, server closes each time', () => viaFetch('/close'));
  await run('https Agent keepAlive: true', ((a) => () => get('/', a))(new https.Agent({ keepAlive: true, ca: cert })));
  await run('https Agent keepAlive: false', ((a) => () => get('/', a))(new https.Agent({ keepAlive: false, ca: cert })));
  console.log('server keepAliveTimeout 1000 ms, pauses between bursts 1.5-2.5 s');
  await stale('Agent keepAlive, no idle timeout', ((a) => () => get('/', a))(new https.Agent({ keepAlive: true, ca: cert })));
  await stale('Agent keepAlive, timeout 1500 ms', ((a) => () => get('/', a))(new https.Agent({ keepAlive: true, timeout: 1500, ca: cert })));
  proxy.close(); server.closeAllConnections(); server.close();
  setTimeout(() => process.exit(0), 100);
})();
node check.cjs            # 150 ms RTT
RTT=380 node check.cjs    # Vietnam to Oregon

Output on the machine described above:

node v23.5.0, simulated RTT 150 ms, 40 sequential requests each
fetch, default                     total   6929 ms  median  157 ms  connections 2
fetch, server closes each time     total  18531 ms  median  463 ms  connections 40
https Agent keepAlive: true        total   6539 ms  median  156 ms  connections 1
https Agent keepAlive: false       total  18539 ms  median  464 ms  connections 40
server keepAliveTimeout 1000 ms, pauses between bursts 1.5-2.5 s
Agent keepAlive, no idle timeout   errors 3/300
Agent keepAlive, timeout 1500 ms   errors 0/300