Uploading files efficiently to S3 isn't just about getting data from point A to point B—it's about doing it fast, reliably, and at scale. Whether you're handling 5MB images or 5GB videos, the right approach makes all the difference.
📑 Table of Contents
- The Core Strategy: Direct-to-S3 Uploads
- Basic Presigned URL Upload
- Multipart Upload for Large Files
- Performance Optimizations
- Monitoring Upload Performance
- Handling Upload Failures
- Speed Benchmarks
- Production Checklist
- Key Takeaways
🚀 The Core Strategy: Direct-to-S3 Uploads
Never route files through your server. This is the #1 performance killer.
❌ The Wrong Way (Slow & Resource-Heavy)
Client → Your Server → S3
Problems: Server bottleneck, memory spikes, timeouts, infinite scalability.
✅ The Right Way (Fast & Scalable)
Client → S3 (directly)
Your Server → Generates presigned URL only
Benefits: Maximum speed, no server memory issues, infinite scalability
📦 Implementation: Basic Presigned URL Upload
S3 Service Setup
// s3.service.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class S3Service { private readonly s3Client: S3Client; private readonly bucketName: string; constructor(private configService: ConfigService) { this.s3Client = new S3Client({ region: this.configService.get('AWS_REGION'), credentials: { accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'), secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'), }, // Performance optimizations maxAttempts: 3, requestHandler: { connectionTimeout: 5000, socketTimeout: 5000, }, }); this.bucketName = this.configService.get('AWS_BUCKET_NAME'); } async generatePresignedUrl( filename: string, contentType: string, fileSize: number, ): Promise<{ uploadUrl: string; key: string }> { const key = `uploads/${uuidv4()}/${filename}`; const command = new PutObjectCommand({ Bucket: this.bucketName, Key: key, ContentType: contentType, ContentLength: fileSize, }); // Short expiration for security, adequate for upload speed const uploadUrl = await getSignedUrl(this.s3Client, command, { expiresIn: 900, // 15 minutes }); return { uploadUrl, key }; } } Upload Controller
// upload.controller.ts import { Controller, Post, Body, UseGuards } from '@nestjs/common'; import { UploadService } from './upload.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @Controller('api/upload') @UseGuards(JwtAuthGuard) export class UploadController { constructor(private readonly uploadService: UploadService) {} @Post('initiate') async initiateUpload(@Body() body: { filename: string; contentType: string; fileSize: number; }) { return await this.uploadService.initiateUpload( body.filename, body.contentType, body.fileSize, ); } } Client-Side Upload with Progress
import axios from 'axios'; async function uploadFileToS3(file: File, token: string) { // Step 1: Get presigned URL const { data } = await axios.post( '/api/upload/initiate', { filename: file.name, contentType: file.type, fileSize: file.size, }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); const { uploadUrl, key } = data; // Step 2: Upload directly to S3 with progress tracking await axios.put(uploadUrl, file, { headers: { 'Content-Type': file.type, }, onUploadProgress: (progressEvent) => { if (progressEvent.total) { const percentage = Math.round((progressEvent.loaded / progressEvent.total) * 100); updateProgressBar(percentage); } }, }); return key; } 🚄 Multipart Upload: For Large Files (100MB+)
For files over 100MB, use S3's multipart upload. This provides:
- Faster uploads: Parallel part uploads
- Resumable uploads: Retry individual failed parts
- Better reliability: Network issues don't kill the entire upload
Multipart Upload Service
// multipart.service.ts import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { S3Client, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class MultipartService { private readonly s3Client: S3Client; private readonly bucketName: string; // Optimal chunk size for network efficiency private readonly CHUNK_SIZE = 10 * 1024 * 1024; // 10MB constructor(private configService: ConfigService) { this.s3Client = new S3Client({ region: this.configService.get('AWS_REGION'), credentials: { accessKeyId: this.configService.get('AWS_ACCESS_KEY_ID'), secretAccessKey: this.configService.get('AWS_SECRET_ACCESS_KEY'), }, }); this.bucketName = this.configService.get('AWS_BUCKET_NAME'); } async initiateMultipartUpload( filename: string, contentType: string, ) { const key = `uploads/${uuidv4()}/${filename}`; const command = new CreateMultipartUploadCommand({ Bucket: this.bucketName, Key: key, ContentType: contentType, }); const response = await this.s3Client.send(command); return { uploadId: response.UploadId, key: key, chunkSize: this.CHUNK_SIZE, }; } async getPresignedPartUrl( key: string, uploadId: string, partNumber: number, ): Promise<string> { const command = new UploadPartCommand({ Bucket: this.bucketName, Key: key, UploadId: uploadId, PartNumber: partNumber, }); // Longer expiration for large file parts return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 // 1 hour }); } async completeMultipartUpload( key: string, uploadId: string, parts: Array<{ PartNumber: number; ETag: string }>, ) { const command = new CompleteMultipartUploadCommand({ Bucket: this.bucketName, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) }, }); return await this.s3Client.send(command); } async abortMultipartUpload(key: string, uploadId: string) { const command = new AbortMultipartUploadCommand({ Bucket: this.bucketName, Key: key, UploadId: uploadId, }); await this.s3Client.send(command); } } Multipart Controller
// multipart.controller.ts import { Controller, Post, Body, UseGuards } from '@nestjs/common'; import { MultipartService } from '../s3/multipart.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; @Controller('api/upload/multipart') @UseGuards(JwtAuthGuard) export class MultipartController { constructor(private readonly multipartService: MultipartService) {} @Post('initiate') async initiateMultipart( @Body() body: { filename: string; contentType: string; fileSize: number }, ) { return await this.multipartService.initiateMultipartUpload( body.filename, body.contentType, ); } @Post('part-url') async getPartUrl( @Body() body: { key: string; uploadId: string; partNumber: number }, ) { const url = await this.multipartService.getPresignedPartUrl( body.key, body.uploadId, body.partNumber, ); return { url }; } @Post('complete') async completeMultipart( @Body() body: { key: string; uploadId: string; parts: Array<{ PartNumber: number; ETag: string }>; }, ) { return await this.multipartService.completeMultipartUpload( body.key, body.uploadId, body.parts, ); } @Post('abort') async abortMultipart(@Body() body: { key: string; uploadId: string }) { await this.multipartService.abortMultipartUpload(body.key, body.uploadId); return { success: true }; } } Client-Side Multipart Upload with Parallel Parts
import axios from "axios"; class MultipartUploader { constructor(file, options = {}) { this.file = file; this.chunkSize = options.chunkSize || 10 * 1024 * 1024; // 10MB this.maxConcurrent = options.maxConcurrent || 3; // Upload 3 parts simultaneously this.onProgress = options.onProgress || (() => {}); this.uploadedBytes = 0; } async upload() { // Step 1: Initiate multipart upload const { data: initData } = await axios.post( '/api/upload/multipart/initiate', { filename: this.file.name, contentType: this.file.type, fileSize: this.file.size, }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); const { uploadId, key, chunkSize } = initData; this.chunkSize = chunkSize; // Step 2: Calculate parts const numParts = Math.ceil(this.file.size / this.chunkSize); const parts = Array.from({ length: numParts }, (_, i) => i + 1); const completedParts: any[] = []; // Step 3: Upload parts in batches (concurrency limit) while (parts.length > 0) { const batch = parts.splice(0, this.maxConcurrent); const batchPromises = batch.map(async (partNumber) => { const start = (partNumber - 1) * this.chunkSize; const end = Math.min(start + this.chunkSize, this.file.size); const blob = this.file.slice(start, end); // Get presigned URL for this part const { data: partData } = await axios.post( '/api/upload/multipart/get-presigned-url', { key, uploadId, partNumber }, { headers: { Authorization: `Bearer ${token}` } } ); const { uploadUrl } = partData; // Upload the part await axios.put(uploadUrl, blob, { headers: { 'Content-Type': this.file.type }, onUploadProgress: (e) => { if (e.total) { const percentage = Math.round((e.loaded / e.total) * 100); updateProgressBar(partNumber, percentage); } }, }); return { PartNumber: partNumber, ETag: partData.etag }; }); const results = await Promise.all(batchPromises); completedParts.push(...results); } // Step 4: Complete multipart upload await axios.post( '/api/upload/multipart/complete', { key, uploadId, parts: completedParts }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } } ); return key; } async uploadPart(key: string, uploadId: string, partNumber: number) { // Step 1: Get presigned URL for this part const { data: urlData } = await axios.post( '/api/upload/multipart/part-url', { key, uploadId, partNumber }, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, } ); const { url } = urlData; // Step 2: Extract chunk const start = (partNumber - 1) * this.chunkSize; const end = Math.min(start + this.chunkSize, this.file.size); const chunk = this.file.slice(start, end); // Step 3: Upload chunk via Axios const response = await axios.put(url, chunk, { headers: { 'Content-Type': this.file.type }, onUploadProgress: (e) => { if (e.total) { this.uploadedBytes += e.loaded; const totalProgress = (this.uploadedBytes / this.file.size) * 100; this.onProgress(totalProgress); } }, }); // Step 4: Extract ETag from response headers const etag = response.headers['etag']; if (!etag) throw new Error(`Part ${partNumber} upload failed: missing ETag`); return { PartNumber: partNumber, ETag: etag.replace(/"/g, ''), }; } } // Usage const uploader = new MultipartUploader(file, { maxConcurrent: 5, // Upload 5 parts at once for faster speed onProgress: (percentage) => { console.log(`Upload progress: ${percentage.toFixed(2)}%`); updateProgressBar(percentage); }, }); await uploader.upload(); ⚡ Performance Optimizations
1. S3 Transfer Acceleration
Enable S3 Transfer Acceleration for 50-500% faster uploads over long distances:
// In S3Service constructor this.s3Client = new S3Client({ region: this.configService.get('AWS_REGION'), credentials: { /* ... */ }, useAccelerateEndpoint: true, // Enable Transfer Acceleration }); Setup: Enable in S3 bucket settings → Properties → Transfer Acceleration
2. Optimal Chunk Sizes
| File Size | Recommended Chunk Size | Reason |
|---|---|---|
| < 100MB | Single upload | Overhead not worth it |
| 100MB - 1GB | 10MB chunks | Balance speed/reliability |
| 1GB - 5GB | 25MB chunks | Fewer API calls |
| > 5GB | 100MB chunks | Maximum efficiency |
/** * Calculates the optimal S3 multipart upload chunk size (in bytes) * based on the total file size to balance speed, reliability, and API efficiency. */ calculateOptimalChunkSize(fileSize: number): number { if (fileSize < 100 * 1024 * 1024) return fileSize; // Single upload if (fileSize < 1024 * 1024 * 1024) return 10 * 1024 * 1024; // 10MB if (fileSize < 5 * 1024 * 1024 * 1024) return 25 * 1024 * 1024; // 25MB return 100 * 1024 * 1024; // 100MB } Tips:
- For unstable networks → smaller chunks (5–10MB) for easier retries
- For high-speed connections → larger chunks (25–100MB) for better throughput
- AWS caps at 10,000 parts, so chunk size × parts ≤ total file size
- Combine with parallel uploads (e.g.,
Promise.allSettled()) to fully utilize bandwidth
3. Parallel Upload Configuration
import axios from 'axios'; const OPTIMAL_CONCURRENCY = { // based on network speed slow: 2, // < 5 Mbps medium: 3, // 5-50 Mbps fast: 5, // 50-100 Mbps veryFast: 8, // > 100 Mbps }; // auto-detect network speed async function detectNetworkSpeed() { const start = Date.now(); // request to get a 1MB test file const response = await axios.get('https://your-cdn.com/test-1mb.bin', { responseType: 'blob', // ensure we get the binary data }); // accessing the blob ensures the download fully completes const blob = response.data; const duration = (Date.now() - start) / 1000; // seconds const speedMbps = (blob.size * 8) / (1024 * 1024 * duration); // convert bytes to megabits if (speedMbps < 5) return OPTIMAL_CONCURRENCY.slow; if (speedMbps < 50) return OPTIMAL_CONCURRENCY.medium; if (speedMbps < 100) return OPTIMAL_CONCURRENCY.fast; return OPTIMAL_CONCURRENCY.veryFast; } 4. Connection Pooling & Keep-Alive
// s3.service.ts import { NodeHttpHandler } from '@smithy/node-http-handler'; import { Agent } from 'https'; constructor(private configService: ConfigService) { const agent = new Agent({ keepAlive: true, maxSockets: 50, // Allow multiple concurrent connections keepAliveMsecs: 1000, }); this.s3Client = new S3Client({ region: this.configService.get('AWS_REGION'), credentials: { /* ... */ }, requestHandler: new NodeHttpHandler({ httpsAgent: agent, connectionTimeout: 5000, socketTimeout: 5000, }), }); } 📊 Monitoring Upload Performance
// upload.service.ts @Injectable() export class UploadService { async trackUploadMetrics( key: string, fileSize: number, duration: number, ) { const speedMbps = (fileSize * 8) / (duration * 1024 * 1024); // Log to monitoring service (DataDog, CloudWatch, etc.) this.logger.log({ event: 'upload_completed', key, fileSize, duration, speedMbps, timestamp: new Date(), }); // Alert if speed is below threshold if (speedMbps < 1) { this.alertService.warn('Slow upload detected', { key, speedMbps, }); } } } 🛡️ Handling Upload Failures Gracefully
Retry Logic with Exponential Backoff
async function uploadPartWithRetry(chunk, url, maxRetries = 3) { let lastError; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await uploadChunk(chunk, url); } catch (error) { lastError = error; if (attempt < maxRetries) { // Exponential backoff: 1s, 2s, 4s const delay = Math.pow(2, attempt) * 1000; await new Promise(resolve => setTimeout(resolve, delay)); } } } throw lastError; } Resume Failed Uploads
class ResumableUploader extends MultipartUploader { constructor(file, options = {}) { super(file, options); this.uploadState = this.loadUploadState() || { uploadId: null, key: null, completedParts: [], }; } saveUploadState() { localStorage.setItem( `upload_${this.file.name}`, JSON.stringify(this.uploadState) ); } loadUploadState() { const saved = localStorage.getItem(`upload_${this.file.name}`); return saved ? JSON.parse(saved) : null; } async upload() { // Resume existing upload if available if (this.uploadState.uploadId) { return await this.resumeUpload(); } // Start new upload return await super.upload(); } async resumeUpload() { const { uploadId, key, completedParts } = this.uploadState; const completedPartNumbers = new Set( completedParts.map(p => p.PartNumber) ); // Upload only remaining parts const numParts = Math.ceil(this.file.size / this.chunkSize); const remainingParts = []; for (let i = 1; i <= numParts; i++) { if (!completedPartNumbers.has(i)) { remainingParts.push(i); } } // Upload remaining parts // Complete upload localStorage.removeItem(`upload_${this.file.name}`); } } 📈 Speed Benchmarks
Efficient / Ideal upload speeds for a 1GB file:
| Method | Time | Speed | Notes |
|---|---|---|---|
| Through server | 4-6 min | ~2.5 MB/s | Bottleneck |
| Direct presigned URL | 1.5-2 min | ~8 MB/s | Good |
| Multipart (3 parts) | 45-60 sec | ~17 MB/s | Better |
| Multipart (5 parts) + Acceleration | 30-40 sec | ~25 MB/s | Best |
✅ Production Checklist
- ✅ Direct-to-S3 uploads implemented
- ✅ Multipart upload for files > 100MB
- ✅ Parallel part uploads (3-5 concurrent)
- ✅ S3 Transfer Acceleration enabled
- ✅ Optimal chunk sizes configured
- ✅ Connection pooling enabled
- ✅ Progress tracking implemented
- ✅ Retry logic with exponential backoff
- ✅ Resume capability for failed uploads
- ✅ Upload speed monitoring
- ✅ S3 lifecycle policies for cleanup
- ✅ CloudFront CDN for download speed
🎯 Key Takeaways
- Never route files through your server - Use presigned URLs
- Use multipart uploads for large files (> 100MB)
- Upload parts in parallel - 3-5 concurrent uploads optimal
- Enable S3 Transfer Acceleration - Massive speed boost for global users
- Implement retry logic - Network issues happen
- Monitor upload speeds - Alert on degraded performance
- Optimize chunk sizes - Bigger files need bigger chunks
The difference between a slow upload system and a fast one often comes down to these fundamentals. Get them right, and your users will notice.
Top comments (0)