As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

A few times a day, and never on your laptop, the Node service that calls your Python API logs this.

TypeError: fetch failed cause: Error: read ECONNRESET { code: 'ECONNRESET' } 

On other days it's the cousin, the one people paste into search boxes word for word.

TypeError: fetch failed SocketError: other side closed at Socket.onHttpSocketEnd (node:internal/deps/undici/undici:7147:26) 

curl against the same URL works. A retry works. Somebody already doubled the timeout and nothing changed. It's the bug that only shows up when nobody is watching.

Both messages usually point to one bug: your client reuses an idle connection at the same moment the server closes it. About 30 lines of code reproduce it, and three fixes get before and after numbers. Along the way, the popular explanation for why Node-to-Node calls never fail turns out to be wrong. Half right, anyway.

Where the real error hides

Node's global fetch is built on undici, the HTTP client that ships inside Node. When anything breaks at the socket level, fetch throws a generic fetch failed TypeError and tucks the useful part into err.cause. Log only err.message and a DNS failure, a refused connection and this race all look identical. Log err.cause.code. I'd make that the first of your error handling patterns for production apps. (Log the whole error object and Node prints the cause on a [cause]: line.) The codes that matter here are ECONNRESET and UND_ERR_SOCKET.

Then there's pooling. HTTP/1.1 lets a client keep a connection open and reuse it for the next request, skipping the TCP (and TLS) handshake. undici keeps a pool of these idle sockets per origin. It's the same idea as a database connection pool, with the same weakness. Either side can close an idle connection whenever it likes. Nobody asks the other side first.

Search the error and you land on GitHub. In May 2022, Tim Perry opened undici issue #1415: his requests failed "100% of the time with Undici's fetch implementation", while the same code "works 100% of the time in Chrome, Firefox and node-fetch". He was right. It was a real undici bug, fixed three weeks later.

Then there's #1923, "UND_ERR_SOCKET in fetch", from February 2023. A year in, undici maintainer Matteo Collina replied that "it is extremely likely it is related to some other piece of your infrastructure cutting the connection for whatever reason." It's still open.

Both are right, about different bugs that print the same string. Most people today hit the second kind. And I think "infrastructure" undersells it, because the real cause is far more specific than a flaky network.

The knobs people turn first

The usual advice for fetch failed is to raise the connect timeout, force IPv4 with family: 4, or swap to axios. None of these touch this bug.

connect.timeout governs opening a new TCP connection, and when it fires you get a different code, UND_ERR_CONNECT_TIMEOUT. These failures happen on a socket that's already open, and no timeout fires in any failing run below. Swapping libraries doesn't help either. node-fetch on Node 19+ has had its own consecutive-request failures (node-fetch #1735), because Node 19 turned on keep-alive for http.globalAgent by default. Any client that reuses connections can lose this race.

A 10-millisecond window

Here's a server that behaves like plenty of Python backends. It speaks HTTP/1.1, closes connections after a set idle time, and sends no Keep-Alive header to say so.

# srv.py (Python 3.13 stdlib). Run: python3 srv.py 8801 2 import http.server, sys PORT = int(sys.argv[1]); IDLE = float(sys.argv[2]); HINT = len(sys.argv) > 3 and sys.argv[3] == "hint" class H(http.server.BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" timeout = IDLE # applied to the socket: close after IDLE seconds of silence  def _ok(self): n = int(self.headers.get('Content-Length') or 0); self.rfile.read(n) self.send_response(200); self.send_header('Content-Length', '2') if HINT: self.send_header('Keep-Alive', f'timeout={int(IDLE)}') self.end_headers(); self.wfile.write(b'ok') do_GET = _ok; do_POST = _ok def log_message(self, *a): pass class S(http.server.ThreadingHTTPServer): request_queue_size = 256 S(('127.0.0.1', PORT), H).serve_forever() 

Don't skip the request_queue_size line. Python's default listen backlog is 5. Leave it there, point 100 clients at the server at once, and the sweep dies with connect ECONNRESET 127.0.0.1:8801 before a single socket is reused. I love that your test rig can fake the very error you're hunting.

The client uses only built-in fetch. It fires 40 requests to fill the pool, waits, then fires 40 more that reuse those sockets.

// repro.mjs (Node 24+, no dependencies). Run: node repro.mjs 2000 const url = 'http://127.0.0.1:8801/'; const gap = Number(process.argv[2] ?? 2000); const burst = () => Promise.allSettled(Array.from({ length: 40 }, () => fetch(url).then(r => r.text()))); await burst(); await new Promise(r => setTimeout(r, gap)); const res = await burst(); const bad = res.filter(r => r.status === 'rejected'); console.log(`${process.version} undici ${process.versions.undici}: gap=${gap}ms -> ${bad.length}/40 failed`); if (bad[0]) { const e = bad[0].reason; console.log(String(e)); console.log('cause:', e.cause?.name + ':', e.cause?.message, { code: e.cause?.code }); } 

Run it a few times. In five runs on Node v24.12.0, one prints 33/40 failed with the read ECONNRESET cause from the top of this post. The other four print 0/40 failed. Intermittent, on demand.

One run of 40 can't show the shape, so sweep it. Each trial gets a fresh single-connection pool, sends one request, sleeps for the gap, then reuses the socket for a second. This one needs npm i undici for the per-request dispatcher option.

// sweep.mjs (npm i undici). Run: node sweep.mjs 1990 2000 2010 import { Agent, fetch } from 'undici'; const url = 'http://127.0.0.1:8801/'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function trial(gap) { const dispatcher = new Agent({ connections: 1 }); await sleep(Math.random() * 300); // stagger connects await (await fetch(url, { dispatcher })).text(); await sleep(gap); try { await (await fetch(url, { dispatcher })).text(); return null; } catch (err) { return err.cause?.code; } finally { await dispatcher.close(); } } for (const gap of process.argv.slice(2).map(Number)) { const codes = (await Promise.all(Array.from({ length: 100 }, () => trial(gap)))).filter(Boolean); console.log(`gap=${gap}ms failed=${codes.length}/100`, codes.join(' ')); } 

Against the 2-second server, with 100 trials per gap in 1 ms steps on macOS loopback, this is how many second requests failed.

Gap (ms) Node 24.12.0 (undici 7.16.0) Node 24.22.2 (undici 7.30.1) Node 26.13.0 (undici 8.12.2)
1990–1996 (700 trials) 0 1 7
1997 9 7 7
1998 1 10 10
1999 21 20 13
2000 44 22 25
2001 42 10 13
2002 14 2 1
2003–2010 (800 trials) 20 0 5
Total of 2,100 151 72 81

Read the Node 24.12 column top to bottom. Wait 1,990 ms and every request works. Wait 2,000 ms and 44 out of 100 fail.

Ten milliseconds.

Nearly all the damage sits in a band about a hundredth of a second wide, right on the server's idle timeout. These are single runs, so the counts wobble (an earlier 10 ms-step sweep on the same build gave 33/100 at 2,000 ms). I can't give you an exact rate, and I'd distrust anyone who does. Call it a third to a half at the worst gap.

That's why production sees "a few a day". Your traffic has to pause for almost exactly the server's idle time. Most pauses miss. A handful don't. Each curl process opens a fresh connection, so curl has no idle socket to lose, and your local test loop fires requests back to back with no gap at all.

In every run, ECONNRESET makes up roughly 80 to 90% of failures and UND_ERR_SOCKET the rest. Searching for one string means reading half the threads.

Why the Node server "never fails"

This is where the tidy story falls apart. Run the same sweep against a plain Node http server with keepAliveTimeout = 1000, gaps from 900 to 1,100 ms, and you get zero failures out of 2,100, on both Node 24.12 and 26.13.

The obvious theory, and the one I started with, is that Node servers advertise their timeout and undici politely hangs up first. A raw TCP dump of each server's response shows the first half is true.

--- port 8801 HTTP/1.1 200 OK Server: BaseHTTP/0.6 Python/3.13.5 Date: Wed, 04 Nov 2026 09:36:43 GMT Content-Length: 2 ok --- port 8802 HTTP/1.1 200 OK Date: Wed, 04 Nov 2026 09:36:43 GMT Connection: keep-alive Keep-Alive: timeout=1 Content-Length: 2 ok 

Node advertises a one-second timeout. Python says nothing, so undici falls back to its own 4-second default, twice the server's 2. That mismatch is the whole setup for the race.

The polite-hang-up theory gets the second half wrong. Here's what undici 7.16.0 does with the hint, in lib/dispatcher/client-h1.js.

const timeout = Math.min(keepAliveTimeout - client[kKeepAliveTimeoutThreshold], client[kKeepAliveMaxTimeout]) if (timeout <= 0) { socket[kReset] = true } else { client[kKeepAliveTimeoutValue] = timeout } 

keepAliveTimeoutThreshold is a safety margin undici subtracts from the server's hint, and it defaults to 2 seconds. (It was 1 second before a 2024 fix, and older docs still say so.) With a 1-second hint, 1,000 minus 2,000 is negative, so undici marks the socket as not reusable. It never reuses it at all.

Check it with a raw TCP server that counts connections: point a fresh default Agent at it and send three requests 300 ms apart.

hint=none: server saw 1 connection(s) for 3 requests hint=1: server saw 3 connection(s) for 3 requests hint=2: server saw 3 connection(s) for 3 requests hint=3: server saw 1 connection(s) for 3 requests hint=5: server saw 1 connection(s) for 3 requests 

One digit in a header and keep-alive switches off, which still strikes me as absurd. Node 24.12.0, 24.22.2 and 26.13.0 all give the same counts. The Node server in that sweep was "immune" because undici opened a new connection every time, which is safe the way not driving is safe. Any hint of 2 seconds or less turns reuse off. A hint of N seconds above 2 makes undici close the socket after N minus 2.

Here's the race itself. Undici picks the idle socket, which looks perfectly healthy at that instant, and starts writing your request into it, while at almost the same instant the server's idle timer fires and it sends a FIN, the TCP "I'm done" packet, back down the same wire. They cross. If the FIN lands before any response, undici reports other side closed. If your bytes reach a socket the server has already closed, the server's kernel answers with a reset and you get read ECONNRESET.

It's like posting a letter to a friend at the exact moment they move out. You addressed it correctly. The house was occupied when you wrote it. It's empty when the letter arrives.

In October 2026 Collina closed the long-running undici issue #3492 with "This is fixed. The race was closed by idle socket validation on keep-alive reuse". Newer undici checks an idle socket for a pending FIN or reset before writing to it. That fix is real, and it's probably why the newer columns roughly halve the failures. But it only catches a FIN that has already arrived. One still on the wire is invisible. The easy half of the race is fixed; the half where the server closes during your write can't be fixed from the client. People miss this when they see "fixed" in an issue title.

Making the client hang up first

If the server closes idle sockets after 2 seconds, tell undici to give up sooner. For built-in fetch, install the npm package and replace the global dispatcher.

// fix-a.mjs (Node 24+, npm i undici). Run: node fix-a.mjs import { Agent, setGlobalDispatcher } from 'undici'; setGlobalDispatcher(new Agent({ keepAliveTimeout: 1500 })); const res = await fetch('http://127.0.0.1:8801/'); console.log(res.status, await res.text()); // prints 200 ok 

Does built-in fetch honour a dispatcher from the npm package? It does. Against a connection-counting server, three built-in fetch calls 1.5 s apart use 1 connection by default and 3 after setGlobalDispatcher(new Agent({ keepAliveTimeout: 500 })). That holds for each Node build with its own undici version, and for Node 24.12.0 with npm undici 8.12.2.

Now the numbers. In the 10 ms-step sweep from 1,900 to 2,100 ms (2,100 trials), the default client on Node 24.12 loses 33 requests. With keepAliveTimeout: 1500 it loses 0. On Node 26.13 it goes from 15 to 0.

The cost is that any pause over 1.5 s now opens a fresh connection. Inside a data centre on plain HTTP, that's cheap. Over TLS to a far-away region, you'll feel the handshake.

The other fix lives on the server. Advertise the idle timeout with a Keep-Alive: timeout=N header. A fair test needs a hint above 2 seconds, since a 2-second hint just turns reuse off. So give srv.py a 3-second idle close and sweep gaps from 2,950 to 3,050 ms.

With no hint, a 3,000 ms gap fails 58 times in 100 on Node 24.12.0 (19 on 24.22.2, 18 on 26.13.0). Restart it as python3 srv.py 8805 3 hint to add a 3-second hint, and all three builds drop to zero. Undici now closes after 3 minus 2 = 1 second, two full seconds before the server would.

Simpler still, keep idle sockets open longer than undici's 4-second default. Gunicorn's documented keepalive default is 2 seconds, so a Node client calling a default Gunicorn app is lined up for exactly this race. (Its docs add that the "sync worker does not support persistent connections and will ignore this option", so you'd see it with gthread, gevent or another async worker.) Uvicorn's --timeout-keep-alive defaults to 5 seconds, one second above undici. Both are documented defaults, not measured here.

When a retry is honest

The counterargument to all this tuning is fair. You rarely know every idle timeout between you and the server, since a NAT gateway, a corporate proxy and a load balancer can each have its own. A retry covers all of them, and Collina himself floated "always add the retry interceptor for gets" in issue #3141.

Undici doesn't retry a plain fetch here; GETs fail as readily as POSTs. Retries are opt-in, through RetryAgent or the retry interceptor.

// fix-c.mjs (npm i undici). Run: node fix-c.mjs import { Agent, fetch, interceptors } from 'undici'; const dispatcher = new Agent().compose(interceptors.retry({ maxRetries: 2 })); const res = await fetch('http://127.0.0.1:8801/', { dispatcher }); console.log(res.status, await res.text()); // prints 200 ok await dispatcher.close(); 

On Node 24.12.0, with gaps of 1,998 to 2,002 ms and 100 trials each, GET fails 0 times out of 500. POST fails 155 times out of 500. That's not a bug. It's the design. The retry handler's default methods list is GET, HEAD, OPTIONS, PUT, DELETE and TRACE, with no POST, and its default error codes include both ECONNRESET and UND_ERR_SOCKET.

Leaving POST out is the honest choice. In this repro a hand-rolled POST retry succeeds every time, because the server closed the socket before reading anything. In production, a reset can arrive after the server has charged the card. Retry a POST blindly and you charge it twice. Your customer will notice before your dashboards do. Retry idempotent requests (ones that are safe to repeat), add backoff, and give POSTs an idempotency key if they need retries at all. There's more on that in circuit breakers and retry patterns.

My view: use Fix A or B when you control or know the server, and add the idempotent retry anyway for everything you don't.

What changes on Node 26

Node 26.13.0 became LTS on 28 October 2026 and bundles undici 8.12.2. Its keep-alive defaults are unchanged, at a 4 s timeout, a 2 s threshold and a 600 s maximum. Retries are still opt-in, and a proposal to add a retry option to fetch itself was turned down with "I don't think this will land as it's a new spec addition."

What did change is idle-socket validation, which the latest Node 24 (24.22.2, with undici 7.30.1) has too. That's the halving in the table. Undici 8 also turns on HTTP/2 by default for HTTPS servers that offer it. HTTP/2 runs many requests over one long-lived connection, so the race looks different there (see this HTTP/2 and HTTP/3 primer), and none of these runs test it. Plain http:// calls to internal services are still HTTP/1.1. They still race.

Now the limits. Fix A is only as good as your knowledge of the shortest idle timeout on the path. Everything here is macOS loopback. TLS, Linux, real NAT and serverless platforms that freeze a process between invocations are untested, and on those a socket can sit "idle" for minutes without your code knowing.

fetch failed also has other causes (DNS, refused connections, real connect timeouts, TLS errors), so check err.cause.code first. And a Node server behind an AWS load balancer has the mirror-image bug. The ALB's idle timeout defaults to 60 seconds, Node's server.keepAliveTimeout to 5, and AWS recommends the app's timeout be the larger one, or you get 502s.

Back to those few failures a day. They weren't the network, and they weren't random. In the 10 ms sweep on Node 24.12, the default client loses 33 of 2,100 requests and a client that hangs up first loses 0. So on Monday, find the idle timeout of whatever you call, and make sure your client gives up first.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!

101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools

We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva