Skip to content

storage: File#createReadStream triggers MaxListenersExceededWarning on every read since 8.0 (teeny-request ≥ 10) #9755

Open

Description

@witalewski

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/storage 8.4.0 (also seen on 8.1.0)
  • teeny-request 11.1.1 (also seen on 10.0.0 and 11.0.1), node-fetch 3.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

  1. az-nextsec commented on Oct 28, 2026

    @az-nextsec

    Same as #9185

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    api: storageIssues related to the Cloud Storage API.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions