Skip to content

Stream large files to S3 with multipart uploads - #12070

Merged
mjackson merged 4 commits into
mainfrom
mjackson/stream-s3-uploads
Oct 23, 2026
Merged

mjackson merged 4 commits into
mainfrom
mjackson/stream-s3-uploads

Conversation

@mjackson

@mjackson mjackson commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

storage.set() in the S3 backend reads the whole file with arrayBuffer() before it sends a single PUT, so storing a 500 MB file needs 500 MB of RAM even when the file is on disk or is being streamed from somewhere else. This PR streams files to S3 instead.

import { createS3FileStorage } from 'remix/file-storage/s3'

let storage = createS3FileStorage({
  accessKeyId,
  secretAccessKey,
  bucket,
  region,
  partSize: 16 * 1024 * 1024, // optional, defaults to 8 MiB
})

await storage.set('videos/intro.mp4', file) // sent in parts, without buffering the whole file
  • Multipart uploads for large files: set() reads the file in fixed-size parts.
    • Blobs and Files are read with slice().arrayBuffer(). Blob#stream() on Node.js before 24.20.0 and 26.6.0 keeps every chunk in memory for the life of the process (nodejs/node#63574), so it would leak every stored file. A comment notes that this workaround can be removed once the minimum supported Node.js version is >=24.20.0.
    • Other File-shaped inputs are read with stream(). Only stream(), name, type and lastModified are used, so this also works with streaming sources whose size isn't known until they've been read.
    • Files that fit in one part still go up with a single PUT.
    • Larger files use CreateMultipartUpload, UploadPart and CompleteMultipartUpload. Each part uploads while the next one is read, so at most two parts are in memory.
    • Every part except the last is exactly partSize bytes, which providers like Cloudflare R2 require.
  • New partSize option: defaults to 8 MiB, which supports files up to about 78 GiB, and must be at least 5 MiB, the S3 minimum.
  • Failure handling:
    • If a part upload fails or the source stream errors, the multipart upload is aborted and the source stream is canceled.
    • A completion that S3 reports as an error inside a 200 response is treated as a failure.
    • The README recommends a lifecycle rule for incomplete uploads, in case a process exits before it can abort one.
  • put() uses the same upload path. It still holds the whole file in memory because it returns a File with the stored contents, and the docs point large files to set().

Peak memory growth when storing one file read from a stream with set(), using a fetch that discards request bodies (Node.js 24.18):

File size Before After
100 MiB 195 MiB 149 MiB
500 MiB 1,004 MiB 183 MiB

Before, memory grows with the file. After, it stays flat, and what's left is garbage-collection lag on discarded part copies. Storing ten 20 MiB in-memory Files leaves no memory retained after garbage collection.

The unit tests cover part splitting, reading Blobs without stream(), the single-PUT cutoff, how far reading runs ahead of uploading, abort on part and stream failures, and failed completions. A new integration test runs a real multipart upload against LocalStack in CI.

This is independent of #12029 and #12030. Once #12030 lands, streaming uploads from remix/form-data-parser/future can go straight from the request to S3 through set().

@github-actions

Copy link
Copy Markdown
Contributor

Preview Build Available

A preview build has been created for this PR. You can install it using:

pnpm install "remix-run/remix#preview/pr-12070&path:packages/remix"

This preview build will be updated automatically as you push new commits.

`storage.set()` read the whole file into memory with `arrayBuffer()`
before sending a single PUT, so streaming uploads from
`form-data-parser/future` were buffered again on their way to S3.

Files are now read from `file.stream()` in fixed-size parts. Files that
fit in one part still use a single PUT; larger files use an S3 multipart
upload that holds at most two parts in memory and is aborted on failure.
A new `partSize` option sets the part size (default 8 MiB, minimum 5 MiB).
Node.js before 24.20.0 and 26.6.0 retains every chunk read through
Blob#stream(), so streaming native File objects to S3 leaked each stored
file. Blobs are now read in parts with slice().arrayBuffer(), and only
streaming-only inputs such as the future FileUpload use stream().
Document and test streaming sources generically, since this change no
longer depends on form-data-parser/future, and note when the Blob slice
workaround can be removed.
@mjackson
mjackson force-pushed the mjackson/stream-s3-uploads branch from 7dcf54f to 3b2fb70 Compare October 23, 2026 17:11
@mjackson
mjackson changed the base branch from mjackson/streaming-file-uploads to main October 23, 2026 17:11
@mjackson
mjackson merged commit 50ade72 into main Oct 23, 2026
19 checks passed
@mjackson
mjackson deleted the mjackson/stream-s3-uploads branch October 23, 2026 17:26
@github-actions

Copy link
Copy Markdown
Contributor

The preview branch preview/pr-12070 has been deleted now that this PR is merged/closed.

@remix-run-bot remix-run-bot mentioned this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant