Skip to content

fetch/FormData multipart uploads retain ArrayBuffer memory after request completion in Node.js 24.16+ #65012

Description

@lilachdavis

Version

24.19.0

Platform

Microsoft Windows NT 10.0.20348.0 x64

Subsystem

fetch

What steps will reproduce the bug?

Run the test below, with --expose-gc, using different versions of Node.

The expected results are:
Node 24.16 - 24.19 - FAIL: multipart upload does not release chunk buffers after request completion. Bug reproduced.
Node 24.15 and below - PASS: chunk buffers released after request completion.

'use strict'

const { test } = require('node:test')
const { createServer } = require('node:http')
const { once } = require('node:events')
const assert = require('node:assert')

function printMemory(label) {
  const memory = process.memoryUsage()

  console.log(label)
  console.log(`  RSS=${Math.round(memory.rss / 1024 / 1024)}MB`)
  console.log(`  Heap=${Math.round(memory.heapUsed / 1024 / 1024)}MB`)
  console.log(`  External=${Math.round(memory.external / 1024 / 1024)}MB`)
  console.log(`  ArrayBuffers=${Math.round(memory.arrayBuffers / 1024 / 1024)}MB`)
}

test(
  'multipart upload should release chunk buffers after request completion',
  { timeout: 300000 },
  async (t) => {
    const server = createServer((req, res) => {
      // Fully consume the multipart request body
      req.on('data', () => {})
      req.on('end', () => {
        res.writeHead(200)
        res.end()
      })
    })

    t.after(() => server.close())

    server.listen(0)
    await once(server, 'listening')

    const { port } = server.address()
    const url = `http://localhost:${port}/upload`

    const chunkSize = 5 * 1024 * 1024
    const numberOfChunks = 200 // 1GB total

    const startMemory = process.memoryUsage()

    printMemory('Before upload')

    for (let i = 0; i < numberOfChunks; i++) {
      const chunkData = Buffer.alloc(chunkSize, 0x41)

      const formData = new FormData()

      formData.append(
        'file_content',
        new Blob([chunkData]),
        'test.bin'
      )

      const response = await fetch(url, {
        method: 'PATCH',
        headers: {
          'Content-Range': `bytes ${i * chunkSize}-${(i + 1) * chunkSize - 1}/${numberOfChunks * chunkSize}`,
        },
        body: formData,
      })

      if (!response.ok) {
        throw new Error(`Upload failed: ${response.status}`)
      }

      await response.text()

      // Allow buffers to be collected if they are no longer referenced
      if (global.gc) {
        global.gc()
      }

      if ((i + 1) % 20 === 0) {
        printMemory(
          `After ${(i + 1) * chunkSize / 1024 / 1024}MB uploaded`
        )
      }
    }

    const endMemory = process.memoryUsage()

    printMemory('After upload')

    const arrayBufferGrowth =
      endMemory.arrayBuffers - startMemory.arrayBuffers

    assert.ok(
      endMemory.arrayBuffers - startMemory.arrayBuffers < 200 * 1024 * 1024,
      `ArrayBuffer memory grew by ${Math.round(
        (endMemory.arrayBuffers - startMemory.arrayBuffers) / 1024 / 1024
      )}MB`
    )
  }
)

How often does it reproduce? Is there a required condition?

It reproduces every time I use Node 24.16 - 24.19

What is the expected behavior? Why is that the expected behavior?

After each upload request completes, the multipart request body should become eligible for garbage collection.
RSS and arrayBuffers should remain approximately constant regardless of the total amount of data uploaded.

What do you see instead?

  • Each uploaded chunk appears to remain referenced after the request completes.
  • Uploading a 1GB file as 5MB multipart chunks results in approximately 1GB of retained arrayBuffers.
  • Heap usage remains stable, but arrayBuffers grows roughly in proportion to the uploaded data.
  • Constructing the multipart body manually (without FormData) avoids the issue

Additional information

When repeatedly uploading multipart/form-data requests using FormData and Blob, process RSS and process.memoryUsage().arrayBuffers grow approximately in proportion to the total uploaded data. The memory is not reclaimed after the requests complete, even after forcing garbage collection.

  • Same upload logic on Node.js 24.15.0 does not exhibit the behaviour.
  • Changing Blob construction did not affect the result.
  • The upload wrapper does not retain request objects.
  • Replacing FormData with a manually constructed multipart request body immediately resolved the memory growth.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions