The service generated PDF reports for a logistics client. Memory rose steadily from boot, and Kubernetes killed it around the six-hour mark.
Three snapshots, one comparison
Take a heap snapshot after boot, another after 1,000 requests, and a third after 2,000. Compare the second and third. Whatever grows by the same amount between them is your leak.
The comparison pointed at closures retained by an EventEmitter. Each request registered a progress listener with on() and never removed it.
// before
jobs.on("progress", report);
// after
jobs.once("done:" + id, report);
Memory now sits flat at 180 MB. We also added a check to CI that fails if any emitter passes 50 listeners.
