Repository: nodejs/undici

Problem statement:
fetch memory leak
## Bug Description

`undici.fetch()` continues to use more memory the more requests made

## Reproducible By

Run `npm add undici@7.0.0` to install undici and then run the following code:

```js
const { fetch } = require('undici');
async function main() {
  for (var i = 0; i <= 5000; i++) {
    const res = await fetch(`https://jsonplaceholder.typicode.com/photos/${i}`);
    if (i % 50 === 0) {
      console.log({
        i: i.toString().padStart(4, '0'),
        status: res.status,
        mem: process.memoryUsage.rss().toString().padStart(9, '0')
      });
    }
  }
}
main().catch(console.error);
```

## Expected Behavior

Sometime during the 5000 requests, we should see memory decrease, preferably back down to the initial size around 90MB. Instead we see memory continue to increase up to 136MB.

## Logs & Screenshots

Here is the resulting output:

```js
{ i: '0000', status: 404, mem: '069959680' }
{ i: '0050', status: 200, mem: '090046464' }
{ i: '0100', status: 200, mem: '093208576' }
{ i: '0150', status: 200, mem: '095010816' }
{ i: '0200', status: 200, mem: '089260032' }
{ i: '0250', status: 200, mem: '092127232' }
{ i: '0300', status: 200, mem: '094109696' }
{ i: '0350', status: 200, mem: '096436224' }
{ i: '0400', status: 200, mem: '098844672' }
{ i: '0450', status: 200, mem: '099696640' }
{ i: '0500', status: 200, mem: '099713024' }
{ i: '0550', status: 200, mem: '100122624' }
{ i: '0600', status: 200, mem: '100597760' }
{ i: '0650', status: 200, mem: '102858752' }
{ i: '0700', status: 200, mem: '104398848' }
{ i: '0750', status: 200, mem: '109363200' }
{ i: '0800', status: 200, mem: '109363200' }
{ i: '0850', status: 200, mem: '109379584' }
{ i: '0900', status: 200, mem: '109379584' }
{ i: '0950', status: 200, mem: '108904448' }
{ i: '1000', status: 200, mem: '110575616' }
{ i: '1050', status: 200, mem: '111411200' }
{ i: '1100', status: 200, mem: '112132096' }
{ i: '1150', status: 200, mem: '112132096' }
{ i: '1200', status: 200, mem: '112148480' }
{ i: '1250', status: 200, mem: '112771072' }
{ i: '1300', status: 200, mem: '112836608' }
{ i: '1350', status: 200, mem: '112836608' }
{ i: '1400', status: 200, mem: '113147904' }
{ i: '1450', status: 200, mem: '113147904' }
{ i: '1500', status: 200, mem: '116752384' }
{ i: '1550', status: 200, mem: '118652928' }
{ i: '1600', status: 200, mem: '118669312' }
{ i: '1650', status: 200, mem: '119226368' }
{ i: '1700', status: 200, mem: '120111104' }
{ i: '1750', status: 200, mem: '120487936' }
{ i: '1800', status: 200, mem: '120487936' }
{ i: '1850', status: 200, mem: '120602624' }
{ i: '1900', status: 200, mem: '120684544' }
{ i: '1950', status: 200, mem: '120684544' }
{ i: '2000', status: 200, mem: '121733120' }
{ i: '2050', status: 200, mem: '121749504' }
{ i: '2100', status: 200, mem: '121749504' }
{ i: '2150', status: 200, mem: '121798656' }
{ i: '2200', status: 200, mem: '122748928' }
{ i: '2250', status: 200, mem: '122912768' }
{ i: '2300', status: 200, mem: '122929152' }
{ i: '2350', status: 200, mem: '122994688' }
{ i: '2400', status: 200, mem: '125403136' }
{ i: '2450', status: 200, mem: '126337024' }
{ i: '2500', status: 200, mem: '126353408' }
{ i: '2550', status: 200, mem: '126353408' }
{ i: '2600', status: 200, mem: '126353408' }
{ i: '2650', status: 200, mem: '126353408' }
{ i: '2700', status: 200, mem: '126369792' }
{ i: '2750', status: 200, mem: '126369792' }
{ i: '2800', status: 200, mem: '126500864' }
{ i: '2850', status: 200, mem: '126517248' }
{ i: '2900', status: 200, mem: '126517248' }
{ i: '2950', status: 200, mem: '126795776' }
{ i: '3000', status: 200, mem: '126828544' }
{ i: '3050', status: 200, mem: '127746048' }
{ i: '3100', status: 200, mem: '128286720' }
{ i: '3150', status: 200, mem: '128319488' }
{ i: '3200', status: 200, mem: '128319488' }
{ i: '3250', status: 200, mem: '128319488' }
{ i: '3300', status: 200, mem: '128319488' }
{ i: '3350', status: 200, mem: '128319488' }
{ i: '3400', status: 200, mem: '128319488' }
{ i: '3450', status: 200, mem: '128319488' }
{ i: '3500', status: 200, mem: '128335872' }
{ i: '3550', status: 200, mem: '128335872' }
{ i: '3600', status: 200, mem: '128335872' }
{ i: '3650', status: 200, mem: '128335872' }
{ i: '3700', status: 200, mem: '128335872' }
{ i: '3750', status: 200, mem: '128352256' }
{ i: '3800', status: 200, mem: '128352256' }
{ i: '3850', status: 200, mem: '128614400' }
{ i: '3900', status: 200, mem: '128663552' }
{ i: '3950', status: 200, mem: '128663552' }
{ i: '4000', status: 200, mem: '128663552' }
{ i: '4050', status: 200, mem: '128663552' }
{ i: '4100', status: 200, mem: '128679936' }
{ i: '4150', status: 200, mem: '129253376' }
{ i: '4200', status: 200, mem: '129253376' }
{ i: '4250', status: 200, mem: '129253376' }
{ i: '4300', status: 200, mem: '129253376' }
{ i: '4350', status: 200, mem: '129253376' }
{ i: '4400', status: 200, mem: '130236416' }
{ i: '4450', status: 200, mem: '130236416' }
{ i: '4500', status: 200, mem: '130236416' }
{ i: '4550', status: 200, mem: '130940928' }
{ i: '4600', status: 200, mem: '133595136' }
{ i: '4650', status: 200, mem: '134709248' }
{ i: '4700', status: 200, mem: '134709248' }
{ i: '4750', status: 200, mem: '134725632' }
{ i: '4800', status: 200, mem: '134725632' }
{ i: '4850', status: 200, mem: '134758400' }
{ i: '4900', status: 200, mem: '136708096' }
{ i: '4950', status: 200, mem: '136708096' }
{ i: '5000', status: 200, mem: '136708096' }
```

## Environment

- macOS 15.1.1
- Node.js 22.11.0

### Additional context

The repeated values are suspicious so perhaps I'm doing something wrong.



Hints:
There is no leak. This is tested at https://github.com/nodejs/undici/blob/main/test/fetch/fire-and-forget.js.

What you are experiencing is V8 seeing that there is a lot of memory in the system and it’s being lazy in collecting it


@mcollina How long does it take to collect? 🤔 

I noticed your test uses `node --expose-gc` so I tried that calling `gc(true)` each loop but that didn't work.

I also tried `node --max-old-space-size=100 example.cjs` to limit to 100MB but that didn't work either, it exceeded 100MB.
What you are doing in that example is:

1. issuing `fetch()` requests
2. avoid consuming the body

A body is kept in memory for a while after `fetch()` resolves.

---

> @mcollina How long does it take to collect?

You are not measuring it in your example. You need to monitor `heapUsed` and `heapTotal`.

---

> I also tried node --max-old-space-size=100 example.cjs to limit to 100MB but that didn't work either, it exceeded 100MB.

This means "keep 100MB of old space plus all the other memory Node.js needs". Reaching 130-150MB in that case would be ok.
I've added some more data to your script:

```js
const { fetch } = require('.');
async function main() {
  for (var i = 0; i <= 5000; i++) {
    const res = await fetch(`https://jsonplaceholder.typicode.com/photos/${i}`);
    if (i % 50 === 0) {
      console.log({
        i: i.toString().padStart(4, '0'),
        status: res.status,
        rss: process.memoryUsage().rss.toString().padStart(9, '0'),
        heapUsed: process.memoryUsage().heapUsed.toString().padStart(9, '0'),
        heapTotal: process.memoryUsage().heapTotal.toString().padStart(9, '0')
      });
    }
  }
}
main().catch(console.error);
```

And here are the last few results:

```
{
  i: '4700',
  status: 200,
  rss: '086245376',
  heapUsed: '013260704',
  heapTotal: '015368192'
}
{
  i: '4750',
  status: 200,
  rss: '086228992',
  heapUsed: '010794960',
  heapTotal: '016416768'
}
{
  i: '4800',
  status: 200,
  rss: '086245376',
  heapUsed: '012110992',
  heapTotal: '015368192'
}
{
  i: '4850',
  status: 200,
  rss: '086245376',
  heapUsed: '009548680',
  heapTotal: '015368192'
}
{
  i: '4900',
  status: 200,
  rss: '086245376',
  heapUsed: '010769768',
  heapTotal: '015368192'
}
{
  i: '4950',
  status: 200,
  rss: '086278144',
  heapUsed: '012100208',
  heapTotal: '015368192'
}
{
  i: '5000',
  status: 200,
  rss: '086360064',
  heapUsed: '009805912',
  heapTotal: '015368192'
}
```

As you can see, memory is stable.
> What you are doing in that example is:
> issuing fetch() requests
> avoid consuming the body
> A body is kept in memory for a while after fetch() resolves.

Oh yes, classic mistake 🤦  

I had been stripping the code down to the bare minimum to reproduce the issue. This is closer to what I started with:

```js
const { fetch } = require('undici');
async function getPhoto(i) {
  const res = await fetch(`https://jsonplaceholder.typicode.com/photos/${i}`);
  const status = res.status;;
  const buffer = await res.arrayBuffer();
  return { status, buffer };
}
async function main() {
  for (var i = 0; i <= 5000; i++) {
    const res = await getPhoto(i);
    if (i % 50 === 0 && res.status && res.buffer) {
      console.log(
        i.toString().padStart(4, '0') + ' ' +
        process.memoryUsage().rss.toString().padStart(9, '0')  + ' ' +
        process.memoryUsage().heapUsed.toString().padStart(9, '0')  + ' ' +
        process.memoryUsage().heapTotal.toString().padStart(9, '0')
      );
    }
  }
}
main().catch(console.error);
```

And the results still show RSS continue to climb.

<details>
  <summary>View Output</summary>
  <p>

```
0000 071008256 009882448 018776064
0050 090095616 013469784 020086784
0100 094404608 011058872 029786112
0150 098320384 015244304 030048256
0200 100990976 014350248 030310400
0250 105037824 014720632 030310400
0300 105070592 018526904 030310400
0350 106774528 018642432 032145408
0400 107102208 013181760 031096832
0450 107249664 016931360 031096832
0500 107266048 016032752 031096832
0550 108462080 012931368 047349760
0600 108494848 016664000 047349760
0650 113999872 020463624 047349760
0700 117309440 014893016 047349760
0750 119472128 018588728 047349760
0800 125042688 022287696 047349760
0850 128073728 012345776 047874048
0900 128073728 016057648 047874048
0950 128221184 019773976 047874048
1000 129990656 023564768 048136192
1050 132038656 018142704 048136192
1100 132071424 021759768 048136192
1150 132546560 011677848 048398336
1200 132546560 015274792 048398336
1250 133038080 018869216 048398336
1300 133038080 022446840 048398336
1350 133054464 016831544 048398336
1400 133054464 020388728 048398336
1450 133922816 023976424 048398336
1500 134463488 012854784 048660480
1550 135086080 016561840 048660480
1600 135430144 020138168 048660480
1650 135462912 014598288 048660480
1700 135462912 018112024 048660480
1750 135479296 021659344 048660480
1800 102760448 009999008 011698176
1850 105480192 011254608 015630336
1900 107331584 012334224 021135360
1950 110247936 013957280 021397504
2000 110411776 010678840 021135360
2050 111296512 012895256 021135360
2100 111968256 010572544 029523968
2150 113377280 014218560 029523968
2200 115671040 013352656 029523968
2250 118603776 013849528 030310400
2300 118636544 017339208 030572544
2350 121077760 010764824 047611904
2400 121077760 014227440 047611904
2450 122290176 017683152 047611904
2500 125583360 021159552 047611904
2550 127467520 015600040 047611904
2600 129892352 019052288 047611904
2650 133201920 022524664 047611904
2700 134709248 012369888 047874048
2750 134709248 015869032 047874048
2800 134709248 019340096 047874048
2850 134709248 022801080 047874048
2900 135200768 017271128 047874048
2950 135217152 020741776 047874048
3000 135659520 010681352 048136192
3050 136380416 014495408 048398336
3100 136413184 017988664 048398336
3150 136413184 021417976 048398336
3200 136577024 015913600 048398336
3250 136577024 019341336 048398336
3300 136577024 022761064 048398336
3350 136740864 012587280 048922624
3400 136740864 016024008 048922624
3450 136773632 019474920 048922624
3500 136773632 022882064 048922624
3550 136806400 017398656 048922624
3600 136806400 020811536 048922624
3650 136970240 010700216 049184768
3700 136970240 014262912 049184768
3750 137019392 017837848 049446912
3800 137691136 021403168 049446912
3850 138706944 016010304 049446912
3900 139132928 019578592 049446912
3950 139132928 023030440 049446912
4000 139247616 012387624 049709056
4050 139264000 016038400 049709056
4100 139264000 019486456 049709056
4150 139264000 023000680 049709056
4200 139296768 017456520 049709056
4250 139296768 020909960 049709056
4300 139296768 010710288 049709056
4350 139296768 014165528 049709056
4400 139296768 017620312 049709056
4450 139313152 021141832 049709056
4500 139313152 015613984 049709056
4550 139313152 019030328 049709056
4600 139329536 022440488 049709056
4650 139771904 012355296 049709056
4700 139771904 015790592 049709056
4750 139771904 019252632 049709056
4800 139788288 022700712 049709056
4850 139788288 017150760 049709056
4900 139804672 020591304 049709056
4950 139902976 010401400 049971200
5000 139902976 013832016 049971200
```

  </p>
</details>

My understanding is that RSS is the number to watch if you want to avoid Out Of Memory errors.
Can you get it to Out-of-memory? V8 is really _lazy_ in cleaning things up, so I expect data to accumulate.
Yes, I can get OOM in docker by limiting memory to 75MM. When I bump to 100MB, it doesn't seem to OOM though. Might need more iterations or perhaps it depends on the response body size. Not sure yet.

Here is the repo https://github.com/uncurated-tests/fetch-memory-leak
I can set it to 85mb memory limit and still get OOM.

https://github.com/uncurated-tests/fetch-memory-leak/tree/85mb
Although I'm starting to doubt this is an issue with `fetch()` since I can reproduce the OOM with `https.get()` too.

Bumping the memory up to 60MB seems to fix it.

https://github.com/uncurated-tests/fetch-memory-leak/tree/55mb
We are also experience memory leaks and I see undici connection timeout errors in the log. That in combination with several reposrts about the undici fetch led me here. I am running undici through svelte kit so I am not sure exactly which version we run. undici-types in node_modules has version 6.19.8
@styfle If you can crash the program only at very low amount of memory... then the program needs at least that amount of memory to perform its operation. Now, that amount of memory seems _excessive_ to me, but at its core it's not a leak.

@henryson if you have reproducible errors and/or leaks, please open new issues.
Tbh. I could not reproduce this few months ago.

Set max old space to e.g. 100 MB and run the fire and forget test, and you will see the garbage collector will evict faster and never go significantly above the targetted 100 MB. 

If you of course set the memory to 500 MB and max old space is calculated to be like 350 MB, then it could happen, that the rest of 150 MB is used already by other processes and boom.
> that the rest of 150 MB is used already by other processes and boom.

I believe that is what is happening to me. The example I provided is contrived in that every request returns the same payload. However, in production each response can have very big difference in size (ranging from a 2 KB to 200MB). So if GC isn't reclaiming that memory, then it can cause OOM after 5 days because the available memory isn't enough for a 200MB response.

The reason why I created this issue is that this problem started happened when I switched from `undici.request()` to `fetch()` around October 31 at 12:00:

![image](https://github.com/user-attachments/assets/0cf7664d-cb16-44a5-a7c2-d606c7a4b441)


Memory continues to rise very slowly until the process is restarted. You can see the sharp drop in memory Wed at 12:00 when the process restarted:

<img width="1147" alt="image" src="https://github.com/user-attachments/assets/5dad1826-8f79-4cab-a60b-ec44feb02cb2">

> Memory continues to rise very slowly until the process is restarted.

How is the process restarted?
> How is the process restarted?

Either it gets OOM error and crashes so a new pod is scheduled to replace it or new code is deployed in which case the old pod is stopped and a new pod takes its place.
The screenshot above was from deploying new code which is why you don't see it spike up.

Here's a screenshot from the OOM case:

<img width="1158" alt="image" src="https://github.com/user-attachments/assets/26173e8b-8568-4bb1-92d1-319584f17684">

My 2 cents is that you have a legit memory leak (when it did OOM), but that's not captured by the snippet in the OP.
Let me expand: considering the reports that I've seen, I think it's _likely_ that there is a memory leak lurking in the error (or abort) path of `fetch()` or in some other "rare" condition. We do quite a lot of "fancy" tricks to meet the spec requirements, including abusing `FinalizationRegistry`: we are threading the needle between the GC and the spec/DX, and I suspect we are essentially forgetting to clear up some references in case of errors.

Based on all things I've seen, the OP behavior has no leak and it's consistent with Node.js performance. The increased memory usage is the result of using WebStreams in Node.js.

---

@styfle in your memory graph, you see that the spike of memory is quite sudden. Something happened in your system that caused that spike, and I would start your investigation there.
> in your memory graph, you see that the spike of memory is quite sudden. Something happened in your system that caused that spike, and I would start your investigation there.

Agreed, that is the bigger problem.

> We do quite a lot of "fancy" tricks to meet the spec requirements, including abusing FinalizationRegistry: we are threading the needle between the GC and the spec/DX, and I suspect we are essentially forgetting to clear up some references in case of errors.

I see. I also compared Bun's `fetch()` implementation to see if it also had a similar problem and it does.

https://github.com/uncurated-tests/fetch-memory-leak/tree/bunjs

I'll close this for now since I'm not able to prove a memory leak with these simple examples.

I think for my case, the long term solution is to switch buffering to streaming but that will require all downstream dependencies work with a stream (and also web streams vs node streams are different 😅).
@mcollina I'm reopening this issue because I can reproduce now, even bumping memory up to 128MB max and I still get OOM.

In particular, I'm making a request to a server with a bad tls cert so fetch throws `ERR_TLS_CERT_ALTNAME_INVALID`.

https://github.com/uncurated-tests/fetch-memory-leak/tree/err-tls-cert-altname-invalid
Confirmed.

Patch:
diff --git a/lib/dispatcher/pool.js b/lib/dispatcher/pool.js
--- a/lib/dispatcher/pool.js
+++ b/lib/dispatcher/pool.js
@@ -70,6 +70,20 @@ class Pool extends PoolBase {
       ? { ...options.interceptors }
       : undefined
     this[kFactory] = factory
+
+    this.on('connectionError', (origin, targets, error) => {
+      // If a connection error occurs, we remove the client from the pool,
+      // and emit a connectionError event. They will not be re-used.
+      // Fixes https://github.com/nodejs/undici/issues/3895
+      for (const target of targets) {
+        // Do not use kRemoveClient here, as it will close the client,
+        // but the client cannot be closed in this state.
+        const idx = this[kClients].indexOf(target)
+        if (idx !== -1) {
+          this[kClients].splice(idx, 1)
+        }
+      }
+    })
   }
 
   [kGetDispatcher] () {


Test patch:
diff --git a/test/pool-connection-error-memory-leak.js b/test/pool-connection-error-memory-leak.js
new file mode 100644
--- /dev/null
+++ b/test/pool-connection-error-memory-leak.js
@@ -0,0 +1,158 @@
+'use strict'
+
+const { test } = require('node:test')
+const assert = require('node:assert')
+const { Pool } = require('..')
+const { createServer } = require('node:http')
+const { kClients } = require('../lib/dispatcher/pool-base')
+
+// This test verifies that clients are properly removed from the pool when they encounter connection errors,
+// which is the fix implemented for issue #3895 (memory leak with connection errors)
+test('Pool client count does not grow on repeated connection errors', async (t) => {
+  // Setup a pool pointing to a non-existent server
+  const pool = new Pool('http://localhost:1', {
+    connections: 10,
+    connectTimeout: 100, // Short timeout to speed up the test
+    bodyTimeout: 100,
+    headersTimeout: 100
+  })
+
+  try {
+    const clientCounts = []
+
+    // Track initial client count
+    clientCounts.push(pool[kClients].length)
+
+    // Make several requests that will fail with connection errors
+    const requests = 5
+    for (let i = 0; i < requests; i++) {
+      try {
+        await pool.request({
+          path: `/${i}`,
+          method: 'GET'
+        })
+        assert.fail('Request should have failed with a connection error')
+      } catch (err) {
+        // We expect connection errors, but the error might be wrapped
+        assert.ok(
+          err.code === 'ECONNREFUSED' ||
+          err.cause?.code === 'ECONNREFUSED' ||
+          err.code === 'UND_ERR_CONNECT',
+          `Expected connection error but got: ${err.message} (${err.code})`
+        )
+      }
+
+      // Track client count after each request
+      clientCounts.push(pool[kClients].length)
+
+      // Small delay to allow for client cleanup
+      await new Promise(resolve => setTimeout(resolve, 10))
+    }
+
+    // The key test: verify that client count doesn't increase monotonically,
+    // which would indicate the memory leak that was fixed
+    const maxCount = Math.max(...clientCounts)
+    assert.ok(
+      clientCounts[clientCounts.length - 1] <= maxCount,
+      `Client count should not increase continuously. Counts: ${clientCounts.join(', ')}`
+    )
+
+    // Ensure the last two counts are similar (stabilized)
+    const lastCount = clientCounts[clientCounts.length - 1]
+    const secondLastCount = clientCounts[clientCounts.length - 2]
+
+    assert.ok(
+      Math.abs(lastCount - secondLastCount) <= 1,
+      `Client count should stabilize. Last counts: ${secondLastCount}, ${lastCount}`
+    )
+
+    // Additional verification: make many more requests to check for significant growth
+    const moreRequests = 10
+    const startCount = pool[kClients].length
+
+    for (let i = 0; i < moreRequests; i++) {
+      try {
+        await pool.request({
+          path: `/more-${i}`,
+          method: 'GET'
+        })
+      } catch (err) {
+        // Expected error
+      }
+
+      // Small delay to allow for client cleanup
+      await new Promise(resolve => setTimeout(resolve, 10))
+    }
+
+    const endCount = pool[kClients].length
+
+    // The maximum tolerable growth - some growth may occur due to timing issues,
+    // but it should be limited and not proportional to the number of requests
+    const maxGrowth = 3
+
+    assert.ok(
+      endCount - startCount <= maxGrowth,
+      `Client count should not grow significantly after many failed requests. Start: ${startCount}, End: ${endCount}`
+    )
+  } finally {
+    await pool.close()
+  }
+})
+
+// This test specifically verifies the fix in pool-base.js for connectionError event
+test('Pool clients are removed on connectionError event', async (t) => {
+  // Create a server we'll use to track connection events
+  const server = createServer((req, res) => {
+    res.writeHead(200, { 'Content-Type': 'text/plain' })
+    res.end('ok')
+  })
+
+  await new Promise(resolve => server.listen(0, resolve))
+  const port = server.address().port
+
+  const pool = new Pool(`http://localhost:${port}`, {
+    connections: 3 // Small pool to make testing easier
+  })
+
+  try {
+    // Make an initial successful request to create a client
+    await pool.request({
+      path: '/',
+      method: 'GET'
+    })
+
+    // Save the initial number of clients
+    const initialCount = pool[kClients].length
+    assert.ok(initialCount > 0, 'Should have at least one client after a successful request')
+
+    // Manually trigger a connectionError on all clients
+    for (const client of [...pool[kClients]]) {
+      client.emit('connectionError', 'origin', [client], new Error('Simulated connection error'))
+    }
+
+    // Allow some time for the event to be processed
+    await new Promise(resolve => setTimeout(resolve, 50))
+
+    // After the fix, all clients should be removed when they emit a connectionError
+    assert.strictEqual(
+      pool[kClients].length,
+      0,
+      'All clients should be removed from pool after connectionError events'
+    )
+
+    // Make another request to verify the pool can create new clients
+    await pool.request({
+      path: '/after-error',
+      method: 'GET'
+    })
+
+    // Verify new clients were created
+    assert.ok(
+      pool[kClients].length > 0,
+      'Pool should create new clients after previous ones were removed'
+    )
+  } finally {
+    await pool.close()
+    await new Promise(resolve => server.close(resolve))
+  }
+})
diff --git a/test/tls-cert-leak.js b/test/tls-cert-leak.js
new file mode 100644
--- /dev/null
+++ b/test/tls-cert-leak.js
@@ -0,0 +1,209 @@
+'use strict'
+
+const { test } = require('node:test')
+const assert = require('node:assert')
+const { tspl } = require('@matteo.collina/tspl')
+const { fetch } = require('..')
+const https = require('node:https')
+const fs = require('node:fs')
+const path = require('node:path')
+const { closeServerAsPromise } = require('./utils/node-http')
+
+const hasGC = typeof global.gc !== 'undefined'
+
+// This test verifies that there is no memory leak when handling TLS certificate errors.
+// It simulates the error by using a server with a self-signed certificate.
+test('no memory leak with TLS certificate errors', { timeout: 20000 }, async (t) => {
+  if (!hasGC) {
+    throw new Error('gc is not available. Run with \'--expose-gc\'.')
+  }
+
+  const { ok } = tspl(t, { plan: 1 })
+
+  // Create HTTPS server with self-signed certificate
+  const serverOptions = {
+    key: fs.readFileSync(path.join(__dirname, 'fixtures', 'key.pem')),
+    cert: fs.readFileSync(path.join(__dirname, 'fixtures', 'cert.pem'))
+  }
+
+  // Create a server that always responds with a simple message
+  const server = https.createServer(serverOptions, (req, res) => {
+    res.writeHead(200)
+    res.end('test response')
+  })
+
+  // Start server on a random port
+  await new Promise(resolve => server.listen(0, resolve))
+  const serverUrl = `https://localhost:${server.address().port}`
+
+  t.after(closeServerAsPromise(server))
+
+  // Function to make a request that will trigger a certificate error
+  async function makeRequest (i) {
+    try {
+      // The request will fail with CERT_SIGNATURE_FAILURE or similar
+      // because we're using a self-signed certificate and not telling
+      // Node.js to accept it
+      const res = await fetch(`${serverUrl}/request-${i}`, {
+        signal: AbortSignal.timeout(2000) // Short timeout to prevent hanging
+      })
+      const text = await res.text()
+      return { status: res.status, text }
+    } catch (e) {
+      // In real code, without the fix, this would leak memory
+      if (e?.cause?.code === 'CERT_SIGNATURE_FAILURE' ||
+          e?.cause?.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' ||
+          e?.cause?.code === 'ERR_TLS_CERT_ALTNAME_INVALID') {
+        return { status: 524, text: 'Certificate Error' }
+      }
+      // Return for any other error to avoid test interruption
+      return { status: 500, text: e.message }
+    }
+  }
+
+  // Counter for completed requests
+  let complete = 0
+  const requestCount = 400
+
+  // Track memory usage
+  const measurements = []
+  let baselineMemory = 0
+
+  // Process a batch of requests
+  async function processBatch (start, batchSize) {
+    const promises = []
+    const end = Math.min(start + batchSize, requestCount)
+
+    for (let i = start; i < end; i++) {
+      promises.push(makeRequest(i))
+    }
+
+    await Promise.all(promises)
+    complete += promises.length
+
+    // Measure memory after each batch
+    if (complete % 50 === 0 || complete === end) {
+      // Run GC multiple times to get more stable readings
+      global.gc()
+      await new Promise(resolve => setTimeout(resolve, 50))
+      global.gc()
+
+      const memUsage = process.memoryUsage()
+
+      // Establish baseline after first batch
+      if (measurements.length === 0) {
+        baselineMemory = memUsage.heapUsed
+      }
+
+      measurements.push({
+        complete,
+        heapUsed: memUsage.heapUsed
+      })
+
+      console.log(`Completed ${complete}/${requestCount}: Heap: ${Math.round(memUsage.heapUsed / 1024 / 1024)}MB`)
+
+      // Check memory trend after we have enough data
+      if (measurements.length >= 4) {
+        const hasLeak = checkMemoryTrend()
+        if (hasLeak) {
+          return true // Indicates a leak was detected
+        }
+      }
+    }
+
+    return false // No leak detected
+  }
+
+  // Main test logic
+  async function runTest () {
+    const batchSize = 50
+
+    for (let i = 0; i < requestCount; i += batchSize) {
+      const leakDetected = await processBatch(i, batchSize)
+      if (leakDetected) {
+        // If a leak is detected, fail the test
+        assert.fail('Memory leak detected: heap usage is consistently increasing at a significant rate')
+        return
+      }
+
+      // Check if we have sufficient measurements or have done 350 requests
+      if (measurements.length >= 7 || complete >= 350) {
+        break
+      }
+    }
+
+    // Final check
+    const finalCheckResult = finalMemoryCheck()
+    if (finalCheckResult) {
+      assert.fail(`Memory leak detected: ${finalCheckResult}`)
+    } else {
+      ok(true, 'Memory usage has stabilized')
+    }
+  }
+
+  // Check if memory usage has a concerning trend
+  function checkMemoryTrend () {
+    // Calculate memory growth between each measurement
+    const growthRates = []
+    for (let i = 1; i < measurements.length; i++) {
+      const prev = measurements[i - 1].heapUsed
+      const current = measurements[i].heapUsed
+      growthRates.push((current - prev) / prev)
+    }
+
+    // Calculate growth from baseline
+    const totalGrowthFromBaseline = (measurements[measurements.length - 1].heapUsed - baselineMemory) / baselineMemory
+
+    // Calculate average growth rate
+    const avgGrowthRate = growthRates.reduce((sum, rate) => sum + rate, 0) / growthRates.length
+
+    console.log(`Growth from baseline: ${(totalGrowthFromBaseline * 100).toFixed(2)}%`)
+    console.log(`Average growth rate: ${(avgGrowthRate * 100).toFixed(2)}%`)
+    console.log(`Growth rates: ${growthRates.map(r => (r * 100).toFixed(2) + '%').join(', ')}`)
+
+    // Only flag as leak if all conditions are met:
+    // 1. Consistent growth (majority of measurements show growth)
+    // 2. Average growth rate is significant (>2%)
+    // 3. Total growth from baseline is significant (>20%)
+
+    // Count how many positive growth rates we have
+    const positiveGrowthRates = growthRates.filter(rate => rate > 0.01).length
+
+    return (
+      positiveGrowthRates >= Math.ceil(growthRates.length * 0.75) && // 75% of measurements show growth >1%
+      avgGrowthRate > 0.02 && // Average growth >2%
+      totalGrowthFromBaseline > 0.2 // Total growth >20%
+    )
+  }
+
+  // Final memory check with adjusted requirements
+  function finalMemoryCheck () {
+    if (measurements.length < 4) return false
+
+    // Calculate growth from baseline to the last measurement
+    const totalGrowthFromBaseline = (measurements[measurements.length - 1].heapUsed - baselineMemory) / baselineMemory
+    console.log(`Final growth from baseline: ${(totalGrowthFromBaseline * 100).toFixed(2)}%`)
+
+    // Calculate final slope over the last 150 requests
+    const lastMeasurements = measurements.slice(-3)
+    const finalSlope = (lastMeasurements[2].heapUsed - lastMeasurements[0].heapUsed) /
+                      (lastMeasurements[2].complete - lastMeasurements[0].complete)
+
+    console.log(`Final memory slope: ${finalSlope.toFixed(2)} bytes per request`)
+
+    // Only consider it a leak if:
+    // 1. Total growth is very significant (>25%)
+    if (totalGrowthFromBaseline > 0.25) {
+      return `Excessive memory growth of ${(totalGrowthFromBaseline * 100).toFixed(2)}%`
+    }
+
+    // 2. Memory is still growing rapidly at the end (>2000 bytes per request)
+    if (finalSlope > 2000) {
+      return `Memory still growing rapidly at ${finalSlope.toFixed(2)} bytes per request`
+    }
+
+    return false
+  }
+
+  await runTest()
+})