Designing a Resilient S3 Multipart Upload Strategy for Large Files
How to build a highly reliable and performant file upload system using S3 Multipart Upload APIs with resume-on-failure capabilities.
Handling file uploads is a routine requirement for backend developers. At low volumes and small file sizes (under 100MB), a simple, single-part HTTP PUT request directly to Amazon S3 (using presigned URLs) works flawlessly.
However, as file sizes balloon into gigabytes—such as raw video footage, database backups, or scientific datasets—single-part uploads become highly vulnerable. A single packet loss or connection blip near the end of a 4GB upload forces the client to restart the entire transfer from scratch. Furthermore, S3 caps single-part uploads at exactly 5GB.
To address these limitations, S3 offers the Multipart Upload API. In this technical blueprint, we’ll examine the architecture of a resilient, parallelized, and resumeable S3 multipart upload system.
1. The Multipart Upload Architecture
An S3 Multipart Upload divides a single large file into a set of smaller, distinct chunks (ranging from 5MB up to 5GB per chunk, except for the last part which can be smaller). These chunks are uploaded independently and can be sent in parallel.
┌───────────────────────────┐
│ Initiate Upload (API) │
└─────────────┬─────────────┘
│ (Returns Upload ID)
▼
┌────────────────────────────────────────────────────────┐
▼ (Part 1 - 10MB) ▼ (Part 2 - 10MB) ▼ (Part 3 - 5MB)
┌───────────┐ ┌───────────┐ ┌───────────┐
│ UploadPart│ │ UploadPart│ │ UploadPart│
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ (ETag 1) │ (ETag 2) │ (ETag 3)
└─────────────────────────┼──────────────────────────────┘
▼
┌───────────────────────────┐
│ Complete Upload (API) │
└───────────────────────────┘
The upload workflow consists of three major phases:
- Initiation: The client asks the backend to initiate an upload. The backend calls S3’s
CreateMultipartUpload, which returns a uniqueUploadId. - Part Uploads: The client splits the file and uploads each chunk (along with the
UploadIdand a sequentialPartNumberfrom 1 to 10,000). For each successful part, S3 returns a unique header value called anETag. - Completion: Once all parts are uploaded, the client sends a completion request containing the
UploadIdand a sorted list of allPartNumbers and their correspondingETags. S3 then reconstructs the final file.
2. Architecting for Resilience: Key Strategies
A. Parallelizing Part Uploads
Instead of uploading chunks sequentially, utilize a Worker Pool to upload multiple parts in parallel. This fully saturates the client’s network bandwidth, dramatically reducing total upload times.
B. Resume-on-Failure (State Tracking)
If a user’s browser crashes or network drops, they shouldn’t lose progress. To make the upload resumeable:
- Save the
UploadIdand part boundaries in a persistent datastore (e.g., PostgreSQL). - When the user retries, query S3 using
ListParts(or check your local database) to identify whichPartNumbers were already uploaded successfully. - Skip the completed parts and only request/upload the missing ones.
C. Preventing Orphaned Uploads (Billing Cleanup)
When a multipart upload is initiated, S3 reserves storage space for the incoming chunks. If an upload is abandoned halfway, S3 continues to charge you for those stored, incomplete parts indefinitely.
To prevent massive AWS bills, you must configure an S3 Lifecycle Rule on your bucket to automatically abort incomplete multipart uploads:
// snippet-1
{
"Rules": [
{
"ID": "AbortIncompleteMultipartUploadsAfter7Days",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": {
"DaysAfterInitiation": 7
}
}
]
}
3. Implementation: Parallel Multipart Upload in Go
Here is a complete, concurrent implementation demonstrating how to initiate, upload chunks concurrently using goroutines, and complete an S3 multipart upload using the AWS SDK for Go v2.
// snippet-2
package main
import (
"context"
"fmt"
"io"
"os"
"sync"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
const (
BucketName = "my-production-storage"
ObjectKey = "large-backups/db-backup-2026.tar.gz"
ChunkSize = 10 * 1024 * 1024 // 10MB Chunks
NumWorkers = 5 // Parallel upload threads
)
type PartUploadJob struct {
PartNumber int32
Offset int64
Size int64
}
type PartUploadResult struct {
CompletedPart types.CompletedPart
Err error
}
func main() {
ctx := context.TODO()
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion("us-east-1"))
if err != nil {
panic(err)
}
s3Client := s3.NewFromConfig(cfg)
file, err := os.Open("db-backup-2026.tar.gz")
if err != nil {
panic(err)
}
defer file.Close()
fileInfo, _ := file.Stat()
fileSize := fileInfo.Size()
// 1. Initiate Multipart Upload
initOutput, err := s3Client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{
Bucket: aws.String(BucketName),
Key: aws.String(ObjectKey),
})
if err != nil {
panic(err)
}
uploadID := initOutput.UploadId
fmt.Printf("Multipart Upload initiated. Upload ID: %s\n", *uploadID)
// Calculate parts
var parts []PartUploadJob
var offset int64
var partNum int32 = 1
for offset < fileSize {
size := int64(ChunkSize)
if offset+size > fileSize {
size = fileSize - offset
}
parts = append(parts, PartUploadJob{
PartNumber: partNum,
Offset: offset,
Size: size,
})
offset += size
partNum++
}
jobs := make(chan PartUploadJob, len(parts))
results := make(chan PartUploadResult, len(parts))
// Start Worker Pool
var wg sync.WaitGroup
for w := 1; w <= NumWorkers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
// Read chunk from file
buffer := make([]byte, job.Size)
_, readErr := file.ReadAt(buffer, job.Offset)
if readErr != nil && readErr != io.EOF {
results <- PartUploadResult{Err: readErr}
continue
}
// Upload chunk
partOutput, uploadErr := s3Client.UploadPart(ctx, &s3.UploadPartInput{
Bucket: aws.String(BucketName),
Key: aws.String(ObjectKey),
UploadId: uploadID,
PartNumber: aws.Int32(job.PartNumber),
Body: io.NopCloser(io.NewSectionReader(file, job.Offset, job.Size)),
})
if uploadErr != nil {
results <- PartUploadResult{Err: uploadErr}
continue
}
results <- PartUploadResult{
CompletedPart: types.CompletedPart{
PartNumber: aws.Int32(job.PartNumber),
ETag: partOutput.ETag,
},
}
}
}()
}
// Enqueue jobs
for _, job := range parts {
jobs <- job
}
close(jobs)
// Wait for workers in background
go func() {
wg.Wait()
close(results)
}()
// Collect results
var completedParts []types.CompletedPart
for res := range results {
if res.Err != nil {
// Abort on any failure to clean S3
_, _ = s3Client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{
Bucket: aws.String(BucketName),
Key: aws.String(ObjectKey),
UploadId: uploadID,
})
panic(fmt.Sprintf("Upload failed, transaction aborted: %v", res.Err))
}
completedParts = append(completedParts, res.CompletedPart)
}
// 3. Complete Multipart Upload
_, err = s3Client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{
Bucket: aws.String(BucketName),
Key: aws.String(ObjectKey),
UploadId: uploadID,
MultipartUpload: &types.CompletedMultipartUpload{
Parts: completedParts,
},
})
if err != nil {
panic(err)
}
fmt.Println("Multipart Upload completed successfully!")
}
Summary
Moving away from single-part uploads for files larger than 100MB is a necessity in production systems. By structuring a concurrent S3 multipart upload system backed by automatic lifecycle cleanup and proper part-level state tracking, you can guarantee fast, reliable, and fault-tolerant transfers even over volatile networks.