Upload files to S3 from Express without buffering them in memory
An Express server where browsers upload straight to S3 under a size-capped POST policy, and server-side uploads stream into S3 a few parts at a time, with no multer.
You don’t need to parse multipart/form-data in Express to put files in S3, and you don’t need to hold a file in memory. Mount the files-sdk/express gateway at /api/files, before any body parser. When the browser calls upload(file), the gateway signs an S3 POST form, the browser sends the file straight to the bucket, and S3 enforces your size cap. Express only handles two small JSON requests per file.
When the bytes do have to pass through your server, as with an import endpoint for scripts and other services, pipe the request into files.upload() through a byte counter. The S3 adapter streams it into a multipart upload, 5 MiB parts at a time, and aborts the upload if the stream fails. In both cases memory use stays flat whatever the file size, which multer’s memoryStorage() can’t promise.
Before you start
- An S3 bucket (this guide calls it
uploads) and credentials whose policy allowss3:PutObject,s3:GetObject,s3:DeleteObject, ands3:AbortMultipartUploadon its objects, pluss3:ListBucketon the bucket. - An Express 5 app on Node.js 22 or later.
- Written against files-sdk 3.0, Express 5.2,
@aws-sdk/client-s33.1148, and Node.js 24. The observed results below come from running this code against a local MinIO server (RELEASE.2025-09-06T17-38-46Z).
npm install files-sdk express @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storagepnpm add files-sdk express @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storageyarn add files-sdk express @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storagebun add files-sdk express @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storagenub add files-sdk express @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storageaube add files-sdk express @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner @aws-sdk/lib-storage@aws-sdk/s3-presigned-post builds the POST policy for browser uploads, @aws-sdk/s3-request-presigner signs download links, and @aws-sdk/lib-storage uploads a stream of unknown length as multipart.
Choose an upload path
| Bytes through Express | Memory per upload | Size limit enforced by | |
|---|---|---|---|
Gateway, upload(file) |
No | None | The gateway at presign, then S3’s POST policy |
Gateway, upload(key, file) |
Yes, streamed | A few 5 MiB parts | The gateway, counting bytes |
Your own route into files.upload() |
Yes, streamed | A few 5 MiB parts | Your byte counter |
multer memoryStorage() |
Yes | The whole file, as file.buffer |
multer’s limits.fileSize |
| multer-s3 | Yes, streamed | lib-storage’s default parts | multer’s limits.fileSize |
Use the first row for anything a browser uploads. The bytes never touch your server, so your server’s bandwidth, request timeouts, and memory don’t depend on file size. Use a streamed path when the bytes have to pass through code you run: an import endpoint, a client that can’t reach the bucket, or a plugin that reads the body, such as encryption().
multer’s own README warns that with memory storage, “uploading very large files, or relatively small files in large numbers very quickly, can cause your application to run out of memory”. multer-s3 doesn’t have that problem: its 3.x releases stream each file into S3 through lib-storage’s Upload class, as the streamed paths here do. What it can’t do is take your server out of the byte path, and it ties the route to the AWS SDK. With multer 2.4 and multer-s3 3.0.1, a file over limits.fileSize got LIMIT_FILE_SIZE and left nothing behind in a MinIO bucket, so if you already run multer-s3, it isn’t broken. The reason to move is the direct path.
Create the storage instance
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
export const files = createFiles({
adapter: s3({ bucket: "uploads", region: "us-east-1" }),
});
The S3 adapter uses the AWS credential chain: environment variables, a shared config file, or the instance’s IAM role. Keep files in its own module and pass it into the app. The server uses this instance, and tests can pass one backed by memory instead (Test S3 storage code without AWS does exactly that).
AWS_ACCESS_KEY_ID=your-access-key-id
AWS_SECRET_ACCESS_KEY=your-secret-access-key
# Signs the presign → complete token. Generate with: openssl rand -hex 32
FILES_API_SECRET=a-long-random-string
Set FILES_API_SECRET to the same value on every instance. Without it, each process signs upload tokens with its own random secret and logs a warning, and a complete request that reaches a different instance fails with upload token signature.
Load the signed-in user
The gateway’s authorize hook receives a Web Request, rebuilt from the Node request’s headers. Whatever your session middleware attached to the Express req (Passport’s req.user, express-session’s req.session) isn’t on it. If your auth can resolve a user from headers alone, such as a JWT or a session library that reads cookies, call it inside authorize. If it’s Express middleware, run it first and pass the user through:
import type { RequestHandler } from "express";
import { getSession } from "./session.js";
export interface User {
id: string;
}
// Your session middleware. It may read cookies and headers, but not the body,
// and it doesn't reject: routes decide what a missing user means.
export const loadUser: RequestHandler = async (req, res, next) => {
const session = await getSession(req);
res.locals.user = session
? ({ id: session.userId } satisfies User)
: undefined;
next();
};
getSession stands in for your session lookup. It takes the Express request and returns { userId: string } or null.
The middleware attaches the user but never answers 401 itself, and that matters for the gateway. A keyless upload on an adapter that can’t presign (or behind a plugin that turns presigning off) sends its bytes to the gateway’s own ?op=proxy URL. That request is authorized by the signed token presign minted, not by authorize, and createFilesClient sends only the target’s own headers with it, not the headers you configured. A middleware that rejects requests without a session header would refuse it. Cookie sessions don’t hit this, because the browser sends cookies to its own origin anyway.
Mount the gateway before the body parser
import { AsyncLocalStorage } from "node:async_hooks";
import express from "express";
import { FilesError } from "files-sdk";
import type { Files } from "files-sdk";
import { createFilesRouter } from "files-sdk/api";
import type { FilesOperation } from "files-sdk/api";
import { createRouteHandler } from "files-sdk/express";
import { loadUser } from "./auth.js";
import type { User } from "./auth.js";
// The verbs the browser may call. Everything else is refused.
const ALLOWED = new Set<FilesOperation>([
"upload",
"list",
"head",
"download",
"delete",
]);
export const createApp = (files: Files) => {
const app = express();
const currentUser = new AsyncLocalStorage<User | undefined>();
const filesRouter = createFilesRouter({
files,
maxUploadSize: 100 * 1024 * 1024, // 100 MiB
authorize: ({ operation }) => {
const user = currentUser.getStore();
if (!user) {
throw new FilesError("Unauthorized", "Sign in to manage files");
}
if (!ALLOWED.has(operation)) {
throw new FilesError("ReadOnly", `${operation} is not allowed`);
}
return { keyPrefix: `users/${user.id}/`, maxExpiresIn: 300 };
},
});
const handleFiles = createRouteHandler(filesRouter);
// Mount the gateway before any body parser.
app.all("/api/files", loadUser, (req, res) =>
currentUser.run(res.locals.user, () => handleFiles(req, res))
);
// Body parsers for the rest of the app come after the gateway.
app.use(express.json());
return app;
};
import { createApp } from "./app.js";
import { files } from "./files.js";
createApp(files).listen(3000, () => {
console.log("Listening on http://localhost:3000");
});
What each piece does:
createRouteHandlerbridges Express’s Nodereq/resto the WebRequest/Responsethe gateway speaks, streaming in both directions. It readsreq.originalUrl, so the route still works when mounted inside anexpress.Router().AsyncLocalStoragecarries the user fromloadUserintoauthorize.run()makes the user the store for everythinghandleFilesawaits, so concurrent requests each see their own user.authorizeruns on every gateway request except the proxyPUTdescribed above. ThrowingUnauthorizedanswers401andReadOnlyanswers403. The returnedkeyPrefixis prepended to every key server-side, so a user who asks forreport.pdfreadsusers/<id>/report.pdfand can’t name anyone else’s files. Authorization covers the rest of the constraint, and Isolate each tenant’s files shows what the gateway returns when one user reaches for another’s.express.json()comes after the gateway. A body parser consumes the request stream, and the gateway reads the raw body itself. Withexpress.json()registered first, every JSON gateway call (list,presign,complete, …) answered500with an empty body, and nothing reached the server log. Keyed uploads still worked, becauseexpress.json()skips bodies that aren’tapplication/json, until someone uploaded a.jsonfile. If a parser has to be global, scope it so it skips/api/files.
Let the browser POST to S3
With maxUploadSize set, the S3 adapter answers a presign with a presigned POST: a form URL plus signed fields. Its policy pins the key, requires the Content-Type the browser claimed, and allows 0 bytes to maxUploadSize. A file that already declares a larger size gets 422 (upload exceeds maxUploadSize) at presign, before anything is signed. After the browser’s POST, the client calls complete, and the gateway heads the object to confirm it landed and is within the limit.
The browser sends that form cross-origin, so the bucket needs a CORS rule. In the S3 console, open the bucket’s Permissions tab and edit Cross-origin resource sharing (CORS):
[
{
"AllowedOrigins": ["http://localhost:3000", "https://app.example.com"],
"AllowedMethods": ["POST"],
"MaxAgeSeconds": 3600
}
]
Then upload from the page. createFilesClient from files-sdk/client runs the presign, the POST to S3, and the complete step, and reports progress:
import { createFilesClient } from "files-sdk/client";
const files = createFilesClient(); // talks to /api/files
const input = document.querySelector<HTMLInputElement>("#file");
const bar = document.querySelector<HTMLProgressElement>("#progress");
input?.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) {
return;
}
const stored = await files.upload(file, {
onProgress: ({ fraction }) => {
if (bar) {
bar.value = fraction;
}
},
});
console.log("Stored as", stored.key);
});
The resolved key is relative to the user’s prefix, for example 0afa2d15-….txt, because the server mints it. To download, link to /api/files?op=download&key=<key>. The gateway answers with a 302 to a presigned S3 GET that lives at most 300 seconds, so the download doesn’t pass through Express either. In a React front end, useFiles from files-sdk/react wraps the same client (React).
If the page and the API are served from different origins in development, proxy /api through your dev server so the page and the gateway share an origin. The gateway checks the Origin header on state-changing requests against its own origin, which it derives from the request’s Host header. A proxy that rewrites Host (Vite’s changeOrigin: true) makes presign fail with 403 origin not allowed. Leave the Host header alone, or list the page’s origin in the router’s allowedOrigins.
Stream an upload through the gateway
Give upload a key and the client skips presign and sends one PUT to /api/files with the file as the body:
const stored = await files.upload(`videos/${crypto.randomUUID()}.mp4`, file);
The gateway checks Content-Length against maxUploadSize first, then pipes the body through a byte counter into files.upload(). The S3 adapter hands the stream to lib-storage, which cuts it into 5 MiB parts and keeps up to four part uploads in flight. Against MinIO, a 6 MiB keyed upload landed as a two-part multipart object (its ETag ended in -2). Check key in authorize if keyed uploads should only reach certain folders, since a user can PUT over a key they already wrote.
Stream a request body into S3 without the gateway
Some uploads have no browser: a nightly export pushed by curl, a webhook delivering a file, another service in your stack. For those, write a plain route that streams the request body into files.upload(). Add it inside createApp, after the gateway, and give createApp an options argument so tests can lower the limit:
import { Readable } from "node:stream";
export const createApp = (
files: Files,
{ maxImportSize = 50 * 1024 * 1024 } = {} // 50 MiB
) => {
// …the gateway, as above…
app.put("/imports/:name", loadUser, async (req, res) => {
const user: User | undefined = res.locals.user;
if (!user) {
res.status(401).json({ error: "Sign in first" });
return;
}
// The name becomes part of the key: no slashes, no leading dot.
const name = String(req.params.name);
if (!/^\w[\w.-]{0,199}$/.test(name)) {
res.status(400).json({ error: "Invalid file name" });
return;
}
// Refuse a declared oversize body before reading any of it.
if (Number(req.get("content-length")) > maxImportSize) {
res.status(413).json({ error: "File too large" });
return;
}
// Count bytes as they stream and fail the stream once past the cap.
let received = 0;
const body = (
Readable.toWeb(req) as ReadableStream<Uint8Array>
).pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
received += chunk.byteLength;
if (received > maxImportSize) {
controller.error(new FilesError("Invalid", "File too large"));
return;
}
controller.enqueue(chunk);
},
})
);
// Stop the upload if the client goes away before we answer.
const abort = new AbortController();
res.on("close", () => {
if (!res.writableFinished) {
abort.abort();
}
});
try {
const stored = await files.upload(`imports/${user.id}/${name}`, body, {
contentType: req.get("content-type") ?? "application/octet-stream",
signal: abort.signal,
});
res.status(201).json(stored);
} catch (error) {
if (received > maxImportSize) {
res.status(413).json({ error: "File too large" });
return;
}
console.error("import failed", FilesError.wrap(error));
if (!res.headersSent) {
res.status(502).json({ error: "Upload failed" });
}
}
});
app.use(express.json());
return app;
};
curl -X PUT --data-binary @report.csv -H "Content-Type: text/csv" \
http://localhost:3000/imports/report.csv
files.upload() takes Web streams, not Node streams, so Readable.toWeb(req) converts the request. The cast is there because Node’s stream types and the DOM’s don’t line up when a project includes both libs. The file name is checked against an allowlist because Files SDK passes keys to the adapter as given: on S3, imports/u1/../../users/u2/x is a literal key, and on the filesystem adapter it would be a path.
Against MinIO, with the 50 MiB limit:
| Request | Response | Left in the bucket |
|---|---|---|
3 MiB with Content-Length |
201 and the stored FileInfo |
The object |
60 MiB with Content-Length |
413 before the route read the body |
Nothing |
60 MiB chunked, no Content-Length |
413 once the counter passed 50 MiB |
Nothing, no pending multipart upload |
| 40 MiB, client disconnects after 12 MiB | Connection gone; the log shows Operation aborted with aborted: true |
Nothing, no pending multipart upload |
When the counter errors the stream or the signal aborts, lib-storage’s upload fails and the adapter aborts the multipart upload (leavePartsOnError: false), so no orphaned parts remain. The catch tells the cases apart: received past the limit means the cap tripped, and a FilesError with aborted: true means the client left.
Don’t swap the counter for validation({ maxSize }) on this route. To measure a stream of unknown length, the plugin buffers all of it first, which is the memory spike this route exists to avoid. It’s the right tool for buffered bodies.
Limits and tradeoffs
- Presigned uploads trust the claimed type. S3’s policy rejects a form whose
Content-Typediffers from the one signed, but an HTML file labelledimage/pngpasses. Enforce file-size and content-type limits on presigned uploads covers checking the bytes after they land. - Body-reading plugins turn direct uploads into proxied ones.
contentType(), andvalidation()with a size or type rule, can’t inspect bytes they never see, so they turn offfiles.capabilities.signedUpload. The gateway then routesupload(file)through its proxyPUT, and the bytes pass through Express. - Leave
maxUploadSizeset. Without it the S3 adapter signs a presignedPUTinstead of aPOST. That binds the type but not the size, and needsPUTand theContent-Typeheader in the bucket’s CORS rule. - Streamed uploads aren’t retried.
files.upload()retriesProviderfailures for buffered bodies when you setretries, but a consumed stream can’t be replayed. A failed import has to be sent again by the caller. - The gateway sends no CORS headers. A front end on another origin needs the
corsmiddleware on/api/filesand its origin inallowedOrigins. Serving both from one origin avoids both. - A crashed process leaves parts. If Node dies mid-stream, the abort never runs. Add a bucket lifecycle rule that aborts incomplete multipart uploads after a day or two.
Troubleshooting
Every gateway call fails with gateway responded 500, the response body is empty, and nothing is logged. A body parser ran before the gateway and consumed the request. Move app.use(express.json()) (and urlencoded, raw, text) below the app.all("/api/files", …) line.
origin not allowed (403) on presign or delete, but list works. Only state-changing requests check Origin. The page’s origin doesn’t match the origin the gateway derived from Host and X-Forwarded-Proto. Check what your proxy forwards, or add the page’s origin to allowedOrigins.
Sign in to manage files (401) for a signed-in user. authorize ran outside currentUser.run(), or loadUser didn’t find the session. Make sure the route calls handleFiles inside run(), not before it.
A keyless upload fails with upload failed (401), or with an empty error message. Middleware in front of the gateway rejected the proxy PUT. (The message is empty when that middleware answers with a JSON body whose error is a string.) Use a middleware that attaches the user without rejecting, as loadUser does, and let authorize decide.
upload failed (403) on the direct path. S3 refused the form’s signature or a policy condition: the form expired, the server clock is off, or a field such as Content-Type was changed after signing.
Multipart, progress, and unknown-length stream uploads on S3 require the optional peer dependency '@aws-sdk/lib-storage'. Both streamed paths need it. Install @aws-sdk/lib-storage.
NestJS
On NestJS with the Express platform, use FilesModule.forRoot() from files-sdk/nestjs instead of mounting the route yourself. It mounts the same gateway through Nest’s middleware layer and exposes the Files instance through dependency injection. Nest registers its body parser globally before any middleware, so create the app with bodyParser: false and scope parsers to your own routes. NestJS has the setup.