Repository navigation
Description
Every File#createReadStream() call in @google-cloud/storage 8.x logs two warnings:
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 error listeners added to [PassThrough]. MaxListeners is 10.
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 close listeners added to [PassThrough]. MaxListeners is 10.
That's one warning pair per read. A server that streams objects to clients logs both lines on every request.
Environment
@google-cloud/storage8.4.0 (also seen on 8.1.0)teeny-request11.1.1 (also seen on 10.0.0 and 11.0.1),node-fetch3.3.2- Node.js 26.7.0, macOS
Reproduction
No GCS or emulator needed: a local HTTP server stands in for the media endpoint.
package.json:
{ "type": "module", "dependencies": { "@google-cloud/storage": "8.4.0", "teeny-request": "11.1.1" } }repro.mjs:
import { createServer } from "node:http";
import { createRequire } from "node:module";
import { Writable } from "node:stream";
import { pipeline } from "node:stream/promises";
const require = createRequire(import.meta.url);
const { Storage } = require("@google-cloud/storage");
const { teenyRequest } = require("teeny-request");
let warnings = 0;
process.on("warning", (warning) => {
if (warning.name === "MaxListenersExceededWarning") {
warnings += 1;
console.log(` ${warning.count} '${warning.type}' listeners on ${warning.emitter.constructor.name}`);
}
});
const body = Buffer.alloc(1_000_000, 1);
const server = createServer((_request, response) => {
response.writeHead(200, { "content-type": "video/mp4", "content-length": body.length });
response.end(body);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const endpoint = `http://127.0.0.1:${server.address().port}`;
const sink = () => new Writable({ write: (_chunk, _encoding, done) => done() });
warnings = 0;
console.log("teeny-request stream mode, 1 request:");
await pipeline(teenyRequest({ uri: `${endpoint}/object` }), sink());
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(` -> ${warnings} warnings`);
warnings = 0;
console.log("@google-cloud/storage File#createReadStream, 1 request:");
const file = new Storage({ apiEndpoint: endpoint, projectId: "repro" }).bucket("bucket").file("object");
await pipeline(file.createReadStream({ validation: false }), sink());
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(` -> ${warnings} warnings`);
server.close();Output with 8.4.0 / teeny-request 11.1.1:
teeny-request stream mode, 1 request:
-> 0 warnings
@google-cloud/storage File#createReadStream, 1 request:
11 'error' listeners on PassThrough
11 'close' listeners on PassThrough
-> 2 warnings
Which versions are affected
Same script, with teeny-request pinned through a pnpm override:
| @google-cloud/storage | teeny-request | teeny-request alone | createReadStream |
|---|---|---|---|
| 8.4.0 | 11.1.1 | 0 warnings | 2 warnings |
| 8.4.0 | 9.0.0 | 0 warnings | 0 warnings |
| 7.22.0 | 9.0.0 (its default) | 0 warnings | 0 warnings |
teeny-request on its own stays under the limit. The warning appears only when storage's read path runs on top of teeny-request 10 or later. We also saw it with teeny-request 10.0.0, 10.1.4 and 11.0.1 under storage 8.4.0.
It's a fixed overhead, not a leak
The peak number of error or close listeners on that one PassThrough doesn't depend on the object size, or on whether a range is requested:
| Object size | 8.4.0 / 11.1.1 | 7.22.0 / 9.0.0 |
|---|---|---|
| 1 KB | 8 | 8 |
| 100 KB – 20 MB, whole or ranged | 12 | 9 |
So nothing accumulates. The read path simply attaches one or two more listeners than Node's default limit allows. The listeners on the warned stream include three onError handlers from node-fetch, its body error handler, the stream pipeline/eos callbacks, and teeny-request's own error forwarder (from emitter.rawListeners('error') at the moment of the warning).
Expected
File#createReadStream stays within Node's default listener limit, as it did in 7.x. Alternatively, it raises the limit on its own internal stream (stream.setMaxListeners(...)), so applications don't have to change the process-wide EventEmitter.defaultMaxListeners.
Workaround
We set EventEmitter.defaultMaxListeners = 15 at process start. It works, but it applies process-wide and hides other emitters' warnings in the 11–15 range.
Activity
az-nextsec commentedon Oct 28, 2026
Same as #9185