Almost every client application we take over eventually needs to accept files: profile photos, claim documents, CSV imports, design assets, video. Almost every one of them gets it wrong the same way. Files are POSTed as multipart/form-data straight into the Express API, buffered in memory or into /tmp, then pushed to S3 from the server. It works on a laptop with a 200 KB avatar. It falls over in production the first time a user uploads a 400 MB video from a hotel Wi-Fi connection: the Node process balloons, the event loop stalls, the load balancer times out at 60 seconds, and the retry uploads the whole thing again.
This tutorial builds the pattern we actually ship: the browser uploads directly to object storage using a presigned URL, the API only ever handles small JSON messages, and MongoDB holds the metadata and the state machine. We add multipart uploads for large files, a scan-before-publish step, and an Angular 22 client with real progress and cancellation. Versions are current as of 2026: Node.js 24 LTS, Express 5, Mongoose 8 on MongoDB 8, Angular 22, and AWS SDK v3 (any S3-compatible store — R2, MinIO, Backblaze B2 — works with the same code).
The architecture in one paragraph
The client asks the API for permission to upload. The API validates the request (who is this user, what content type, how big, how many uploads are already in flight), writes an Upload document in state pending, and returns a short-lived presigned PUT URL. The browser PUTs the bytes straight to S3 and reports back. The API confirms the object exists, checks its real size and type, moves the document to ready, and only then does the file become visible in the app. No file byte ever touches your Node process.
Angular ──1. POST /uploads (JSON: name, type, size)──► Express 5 ──► MongoDB (state: pending)
◄──2. { uploadId, url, key } ─────────────────┘
──3. PUT bytes ─────────────────────────────► S3 / R2 (quarantine bucket)
──4. POST /uploads/:id/complete ────────────► Express 5 ─► HeadObject ─► MongoDB (state: ready)
Step 1: the Upload model
Model the upload as a state machine, not a boolean. Every stuck file you will ever have to debug lives in one of these states.
// api/src/models/upload.js
import { Schema, model } from 'mongoose';
const uploadSchema = new Schema({
owner: { type: Schema.Types.ObjectId, ref: 'User', required: true, index: true },
key: { type: String, required: true, unique: true },
bucket: { type: String, required: true },
filename: { type: String, required: true, maxlength: 255 },
contentType: { type: String, required: true },
declaredSize:{ type: Number, required: true, min: 1 },
actualSize: { type: Number },
checksum: { type: String },
state: {
type: String,
enum: ['pending', 'uploaded', 'scanning', 'ready', 'rejected', 'expired'],
default: 'pending',
index: true,
},
rejectedReason: { type: String },
expiresAt: { type: Date, required: true },
}, { timestamps: true });
// Sweep abandoned uploads automatically: TTL index fires on expiresAt,
// but only for documents still pending (partial index).
uploadSchema.index(
{ expiresAt: 1 },
{ expireAfterSeconds: 0, partialFilterExpression: { state: 'pending' } },
);
export const Upload = model('Upload', uploadSchema);
Two details that matter. First, declaredSize is what the client claims; actualSize is what S3 reports. Never trust the first one for billing, quotas or display. Second, the partial TTL index garbage-collects abandoned pending rows without touching real files — a small thing that keeps the collection from growing unbounded on a busy app.
Step 2: issuing a presigned URL
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner zod
// api/src/s3.js
import { S3Client } from '@aws-sdk/client-s3';
export const s3 = new S3Client({
region: process.env.AWS_REGION ?? 'auto',
endpoint: process.env.S3_ENDPOINT, // set for R2/MinIO, omit for AWS
forcePathStyle: Boolean(process.env.S3_ENDPOINT),
});
export const QUARANTINE = process.env.S3_QUARANTINE_BUCKET;
export const PUBLIC_BUCKET = process.env.S3_PUBLIC_BUCKET;
// api/src/routes/uploads.js
import { Router } from 'express';
import { randomUUID } from 'node:crypto';
import { PutObjectCommand, HeadObjectCommand, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { z } from 'zod';
import { s3, QUARANTINE, PUBLIC_BUCKET } from '../s3.js';
import { Upload } from '../models/upload.js';
import { requireAuth } from '../auth.js';
const router = Router();
const ALLOWED = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['image/webp', 'webp'],
['application/pdf', 'pdf'],
]);
const MAX_BYTES = 50 * 1024 * 1024;
const createSchema = z.object({
filename: z.string().min(1).max(255),
contentType: z.string().refine((t) => ALLOWED.has(t), 'unsupported content type'),
size: z.number().int().positive().max(MAX_BYTES),
});
router.post('/uploads', requireAuth, async (req, res) => {
const body = createSchema.parse(req.body);
const inFlight = await Upload.countDocuments({ owner: req.user.id, state: 'pending' });
if (inFlight > 5) return res.status(429).json({ error: 'too many uploads in progress' });
const ext = ALLOWED.get(body.contentType);
const key = `u/${req.user.id}/${randomUUID()}.${ext}`;
const doc = await Upload.create({
owner: req.user.id,
key,
bucket: QUARANTINE,
filename: body.filename,
contentType: body.contentType,
declaredSize: body.size,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
});
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: QUARANTINE,
Key: key,
ContentType: body.contentType,
ContentLength: body.size,
}),
{ expiresIn: 900 },
);
res.status(201).json({ uploadId: doc.id, url, key });
});
export default router;
The presign is where your security policy lives, and it is worth being explicit about what each line buys you:
- The key is server-generated. Never let the client choose the object key, or a crafted
filenameoverwrites someone else's file. The user id in the prefix makes per-user lifecycle rules and audits trivial. ContentTypeandContentLengthare signed into the URL. S3 rejects a PUT whose headers do not match, so the client cannot presign a 2 KB PNG and then upload a 2 GB file.expiresIn: 900. A leaked URL is a fifteen-minute problem, not a permanent one.- A quarantine bucket, fully private. Nothing is publicly readable until it has been checked.
- A concurrency cap per user. Cheap, stops the obvious abuse, and it is a single
countDocumentsagainst an indexed field.
Step 3: confirming the upload server-side
The browser saying "done" is not evidence. Ask S3.
router.post('/uploads/:id/complete', requireAuth, async (req, res) => {
const doc = await Upload.findOne({ _id: req.params.id, owner: req.user.id });
if (!doc) return res.status(404).json({ error: 'not found' });
if (doc.state !== 'pending') return res.json({ state: doc.state });
let head;
try {
head = await s3.send(new HeadObjectCommand({ Bucket: doc.bucket, Key: doc.key }));
} catch {
return res.status(409).json({ error: 'object not found in storage' });
}
if (head.ContentLength > MAX_BYTES || head.ContentLength !== doc.declaredSize) {
await s3.send(new DeleteObjectCommand({ Bucket: doc.bucket, Key: doc.key }));
doc.set({ state: 'rejected', rejectedReason: 'size mismatch' });
await doc.save();
return res.status(400).json({ error: 'size mismatch' });
}
doc.set({ state: 'uploaded', actualSize: head.ContentLength, checksum: head.ETag });
await doc.save();
await scanQueue.add('scan', { uploadId: doc.id }); // BullMQ, see below
res.json({ state: doc.state });
});
Step 4: sniff the real type, then promote
A Content-Type header is a claim. A PDF header that starts with <?php or an SVG containing <script> is how "image upload" becomes stored XSS. Sniff the magic bytes of the first few kilobytes in a background worker, run a virus scan if your risk profile calls for it, and only then copy the object out of quarantine.
// api/src/workers/scan.js
import { Worker } from 'bullmq';
import { GetObjectCommand, CopyObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
import { fileTypeFromBuffer } from 'file-type';
import { s3, QUARANTINE, PUBLIC_BUCKET } from '../s3.js';
import { Upload } from '../models/upload.js';
const SAFE = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']);
export const scanWorker = new Worker('uploads', async (job) => {
const doc = await Upload.findById(job.data.uploadId);
if (!doc || doc.state !== 'uploaded') return;
doc.set({ state: 'scanning' });
await doc.save();
// Read only the first 8 KB — enough for magic-byte detection.
const obj = await s3.send(new GetObjectCommand({
Bucket: QUARANTINE, Key: doc.key, Range: 'bytes=0-8191',
}));
const head = Buffer.from(await obj.Body.transformToByteArray());
const sniffed = await fileTypeFromBuffer(head);
if (!sniffed || !SAFE.has(sniffed.mime) || sniffed.mime !== doc.contentType) {
await s3.send(new DeleteObjectCommand({ Bucket: QUARANTINE, Key: doc.key }));
doc.set({ state: 'rejected', rejectedReason: 'content does not match declared type' });
return void await doc.save();
}
await s3.send(new CopyObjectCommand({
Bucket: PUBLIC_BUCKET,
Key: doc.key,
CopySource: `${QUARANTINE}/${doc.key}`,
MetadataDirective: 'REPLACE',
ContentType: sniffed.mime,
ContentDisposition: `attachment; filename="${encodeURIComponent(doc.filename)}"`,
CacheControl: 'public, max-age=31536000, immutable',
}));
await s3.send(new DeleteObjectCommand({ Bucket: QUARANTINE, Key: doc.key }));
doc.set({ state: 'ready', bucket: PUBLIC_BUCKET });
await doc.save();
}, { connection: { url: process.env.REDIS_URL } });
ContentDisposition: attachment on user-supplied files is not optional if they can ever be served from a domain that holds a session cookie. Serving documents from a separate storage domain, or at minimum forcing download, removes an entire class of XSS. If you are new to BullMQ workers, the setup is covered in our background jobs tutorial.
Step 5: files bigger than a few hundred megabytes
A single PUT is fine up to roughly 100 MB. Past that, one dropped connection wastes the whole transfer. Use S3 multipart: the API creates the upload, presigns a URL per part, and the client uploads parts in parallel and retries only the failed ones.
router.post('/uploads/:id/parts', requireAuth, async (req, res) => {
const doc = await Upload.findOne({ _id: req.params.id, owner: req.user.id, state: 'pending' });
if (!doc) return res.status(404).json({ error: 'not found' });
if (!doc.multipartId) {
const created = await s3.send(new CreateMultipartUploadCommand({
Bucket: doc.bucket, Key: doc.key, ContentType: doc.contentType,
}));
doc.set({ multipartId: created.UploadId });
await doc.save();
}
const parts = Number(req.body.parts); // client-computed: ceil(size / 8MB)
const urls = await Promise.all(
Array.from({ length: parts }, (_, i) =>
getSignedUrl(s3, new UploadPartCommand({
Bucket: doc.bucket, Key: doc.key, UploadId: doc.multipartId, PartNumber: i + 1,
}), { expiresIn: 3600 })),
);
res.json({ uploadId: doc.multipartId, urls });
});
On completion the client sends back the ETag of each part and the API calls CompleteMultipartUploadCommand with the ordered list. Two operational notes people forget: set a lifecycle rule on the bucket to abort incomplete multipart uploads after a day (you pay for orphaned parts), and expose the ETag header via CORS or the browser cannot read it.
The CORS configuration on the bucket needs to allow your origin, the PUT method, and the headers you sign:
[{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type", "content-length"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}]
Step 6: the Angular 22 client
Use HttpClient with reportProgress, not fetch, so you get upload progress events. A signals-based service keeps the component trivial.
// client/src/app/upload.service.ts
import { Injectable, inject, signal } from '@angular/core';
import { HttpClient, HttpEventType } from '@angular/common/http';
import { firstValueFrom, Subject, takeUntil } from 'rxjs';
type Ticket = { uploadId: string; url: string; key: string };
@Injectable({ providedIn: 'root' })
export class UploadService {
private http = inject(HttpClient);
readonly progress = signal(0);
readonly state = signal<'idle' | 'uploading' | 'processing' | 'ready' | 'error'>('idle');
private cancel$ = new Subject<void>();
cancel() { this.cancel$.next(); this.state.set('idle'); this.progress.set(0); }
async upload(file: File) {
this.state.set('uploading');
this.progress.set(0);
const ticket = await firstValueFrom(this.http.post<Ticket>('/api/uploads', {
filename: file.name, contentType: file.type, size: file.size,
}));
await new Promise<void>((resolve, reject) => {
this.http.put(ticket.url, file, {
headers: { 'Content-Type': file.type },
reportProgress: true,
observe: 'events',
}).pipe(takeUntil(this.cancel$)).subscribe({
next: (e) => {
if (e.type === HttpEventType.UploadProgress && e.total) {
this.progress.set(Math.round((e.loaded / e.total) * 100));
}
},
error: reject,
complete: resolve,
});
});
this.state.set('processing');
await firstValueFrom(this.http.post(`/api/uploads/${ticket.uploadId}/complete`, {}));
this.state.set('ready');
}
}
One trap that costs people an afternoon: if you have an auth interceptor that attaches Authorization to every outgoing request, it will attach it to the S3 PUT too, and S3 will reject the request because the header was not part of the signature. Skip the interceptor for absolute URLs that are not your API:
export const authInterceptor: HttpInterceptorFn = (req, next) => {
if (!req.url.startsWith('/api/')) return next(req); // presigned S3 URLs pass through untouched
return next(req.clone({ setHeaders: { Authorization: `Bearer ${token()}` } }));
};
The component is then a handful of lines, with @if driving the UI off the signals:
<input type="file" (change)="pick($event)" [disabled]="svc.state() === 'uploading'" />
@if (svc.state() === 'uploading') {
<progress [value]="svc.progress()" max="100"></progress>
<button (click)="svc.cancel()">Cancel</button>
} @else if (svc.state() === 'processing') {
<p>Checking your file…</p>
}
Because promotion out of quarantine happens in a worker, ready is eventually consistent. Poll GET /uploads/:id for a second or two, or push the state change with the change-stream/SSE pattern from our real-time MEAN tutorial.
Step 7: serving files back
Never make the storage bucket public and paste the URL into an <img> tag unless the content is genuinely public. For anything user-scoped, serve a short-lived presigned GET:
router.get('/uploads/:id/url', requireAuth, async (req, res) => {
const doc = await Upload.findOne({ _id: req.params.id, owner: req.user.id, state: 'ready' });
if (!doc) return res.status(404).json({ error: 'not found' });
const url = await getSignedUrl(
s3, new GetObjectCommand({ Bucket: doc.bucket, Key: doc.key }), { expiresIn: 300 });
res.json({ url });
});
Put a CDN in front for public assets, and because the key contains a UUID the object is immutable — max-age=31536000, immutable is safe and your egress bill drops accordingly.
What about GridFS?
The question comes up in every review, so: use GridFS when you genuinely need files and data in the same database with the same backup and transaction story, when files are small-to-medium, and when you cannot run object storage (an air-gapped deployment, for instance). Otherwise object storage wins on cost, on CDN integration, and on not putting your working set under pressure from binary data. Storing 4 GB of PDFs in the same MongoDB cluster that serves your queries is a performance problem you will pay for later — see our notes on index and aggregation tuning.
A checklist before you ship
- Server generates the key; the client never picks it.
ContentTypeandContentLengthare signed; the quarantine bucket is private.- Magic bytes sniffed server-side; declared type must match.
Content-Disposition: attachmentand a separate serving domain for user files.- Per-user concurrency and size caps, plus a rate limit on the presign endpoint.
- TTL index on abandoned
pendingrows; lifecycle rule to abort stale multipart uploads. - Images re-encoded (sharp) before display if you accept user avatars — it strips EXIF and kills polyglot files.
- Delete from storage when the document is deleted, ideally through the same worker queue so it is retried.
Uploads are one of those features that look like an afternoon and turn into a quarter when the security review lands. If you are adding file handling to an existing MEAN application, or you have an upload path that is already timing out under real traffic, our performance tuning and MEAN Stack consulting teams do this work every week — get in touch with the shape of your problem and we will tell you what we would change.