← Back to Articles
6/8/2026Guest Transmission

chunked uploads resumable downloads

Chunked Uploads and Resumable Downloads - Complete Guide

How Google, Dropbox, YouTube, AWS Handle Large Files


Table of Contents

  1. Why Simple HTTP Upload Breaks for Large Files
  2. HTTP/1.1 Chunked Transfer Encoding
  3. The TUS Protocol - Open Standard for Resumable Uploads
  4. Google Drive Resumable Upload Protocol
  5. AWS S3 Multipart Upload
  6. Dropbox Upload Sessions
  7. YouTube Large Video Upload
  8. Spring Boot Server: Receiving Chunked Uploads
  9. Handling Timeouts and Intermittent Connectivity
  10. Resumable Downloads: HTTP Range Requests
  11. Spring Boot Server: Serving Resumable Downloads
  12. CDN and Partial Content Delivery
  13. Client-Side Resume Logic (JavaScript)

1. Why Simple HTTP Upload Breaks for Large Files

The Naive Approach and Its Failures

POST /upload HTTP/1.1
Content-Type: application/octet-stream
Content-Length: 2147483648
 
[2 GB binary data]

This single-shot upload fails in production for multiple reasons:

Problem 1: Network timeout during upload

  • A 2 GB file on a 10 Mbps connection takes 27 minutes
  • Most HTTP servers have a 30-60 second idle timeout
  • Most API gateways (AWS API Gateway, Nginx) have a 29-second max timeout
  • The upload fails partway through. The client must start over from zero.

Problem 2: Memory exhaustion on the server

  • Naive implementations buffer the entire request body in memory before processing
  • 100 simultaneous 2 GB uploads = 200 GB of memory on one server
  • Server crashes with OutOfMemoryError

Problem 3: No progress visibility

  • Single HTTP POST gives no visibility into how much has been received
  • User has no progress bar. They do not know if the upload is working.

Problem 4: Mobile and intermittent connectivity

  • Mobile network drops for 10 seconds
  • The TCP connection is broken
  • The entire upload must restart

Problem 5: Large file restrictions

  • AWS API Gateway: 10 MB maximum request body
  • Cloudflare Free: 100 MB maximum
  • Nginx default: 1 MB (client_max_body_size)

The Solutions Overview

File size: 100 KB  -> Simple multipart form POST (fine)
File size: 1-10 MB -> Single POST with streaming (fine with proper server config)
File size: 10+ MB  -> Chunked upload protocols (required)
File size: 100+ MB -> Resumable chunked upload (mandatory)
File size: 1+ GB   -> Multipart + CDN bypass + direct-to-storage (S3, GCS)

2. HTTP/1.1 Chunked Transfer Encoding

What It Is

HTTP/1.1 defines Transfer-Encoding: chunked which allows sending data in pieces without knowing the total Content-Length upfront.

This is different from "application-level chunking" (splitting files into pieces). This is the HTTP transport layer splitting the body into chunks.

HTTP Request with chunked transfer encoding:

POST /upload HTTP/1.1
Transfer-Encoding: chunked
Content-Type: application/octet-stream

5\r\n           <- Chunk size in hex (5 bytes)
Hello\r\n       <- Chunk data
6\r\n           <- Next chunk size (6 bytes)
 World\r\n      <- Next chunk data
0\r\n           <- Zero-length chunk = end of body
\r\n            <- Final CRLF

What Chunked Transfer Solves

  • No need to know Content-Length before starting
  • Server can start processing before receiving the full body
  • Works for streaming data (video, sensor readings, generated content)

What Chunked Transfer Does NOT Solve

  • Does NOT allow resume: If the connection drops, there is no way to tell the server "I already sent the first 500 MB, start from there." The server has no concept of a partial upload from a previous connection.
  • Does NOT solve timeouts: If a single chunk takes too long, timeouts still apply.
  • Does NOT solve the restart problem: A dropped connection means the client restarts from zero.

For large file uploads with resume capability, you need an application-level protocol on top of HTTP.


3. The TUS Protocol - Open Standard for Resumable Uploads

TUS (tus.io) is an open protocol specification for resumable file uploads. It is the basis for how many cloud storage products work, though they often implement their own variants.

Used by: Vimeo, Transloadit, Cloudflare Stream, many open-source implementations.

TUS Protocol Flow

Step 1: Create an upload resource (POST)

The client tells the server "I want to upload a 2 GB file" and gets back a unique upload URL.

POST /files HTTP/1.1
Host: storage.example.com
Content-Length: 0
Upload-Length: 2147483648      <- Total file size in bytes
Tus-Resumable: 1.0.0           <- Protocol version
Upload-Metadata: filename dmlkZW8ubXA0,filetype dmlkZW8vbXA0
                               <- Base64-encoded metadata
 
HTTP/1.1 201 Created
Location: /files/abc123def456   <- Unique upload URL for this file
Tus-Resumable: 1.0.0

Step 2: Upload chunks (PATCH)

The client uploads the file in chunks. Each chunk includes the byte offset where it starts.

PATCH /files/abc123def456 HTTP/1.1
Content-Type: application/offset+octet-stream
Content-Length: 5242880        <- This chunk is 5 MB
Upload-Offset: 0               <- Starting from byte 0
Tus-Resumable: 1.0.0
 
[5 MB of binary data]
 
HTTP/1.1 204 No Content
Upload-Offset: 5242880         <- Server confirms it received up to byte 5,242,880

Step 3: Resume after interruption

If the connection drops after uploading 500 MB:

HEAD /files/abc123def456 HTTP/1.1
Tus-Resumable: 1.0.0
 
HTTP/1.1 200 OK
Upload-Offset: 524288000       <- Server received 500 MB (500 * 1024 * 1024)
Upload-Length: 2147483648

The client reads Upload-Offset and resumes from exactly where the server left off:

PATCH /files/abc123def456 HTTP/1.1
Content-Type: application/offset+octet-stream
Content-Length: 10485760       <- Next 10 MB chunk
Upload-Offset: 524288000       <- Resume from 500 MB
Tus-Resumable: 1.0.0
 
[10 MB of binary data starting at byte 524,288,000]

Step 4: Complete the upload

When Upload-Offset equals Upload-Length, the upload is complete.

TUS Chunk Size Strategy

Optimal chunk size = balance between:
  - Network reliability: Unstable connection -> smaller chunks (less re-upload on failure)
  - Upload overhead:     Each chunk = one HTTP request (overhead + latency)
  - Server memory:       Each partial chunk stored until complete

Typical values:
  Mobile (3G/4G):           5  MB chunks
  Broadband stable:         20 MB chunks
  Corporate fast network:   50 MB chunks
  Local/datacenter:        100 MB chunks

Adaptive strategy:
  - Start with 5 MB chunks
  - If chunk succeeds < 2s: increase chunk size by 20%
  - If chunk fails or takes > 10s: decrease chunk size by 20%
  - Minimum: 256 KB (below this, overhead dominates)
  - Maximum: 100 MB (balance memory and retry cost)

4. Google Drive Resumable Upload Protocol

Google Drive's resumable upload is their production implementation for large files. The protocol is similar to TUS but Google-specific.

Step 1: Initiate the Resumable Upload Session

POST https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable
Authorization: Bearer [ACCESS_TOKEN]
Content-Type: application/json; charset=UTF-8
X-Upload-Content-Type: video/mp4
X-Upload-Content-Length: 2000000000
 
{
  "name": "My Movie.mp4",
  "parents": ["folder_id_here"]
}
 
HTTP/1.1 200 OK
Location: https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&upload_id=xa298sd_sdlkj2982

The Location header contains the resumable session URI. This URI is valid for one week. If the upload is interrupted, the client can use this URI to resume without re-authenticating.

Step 2: Upload File Content in Chunks

PUT https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&upload_id=xa298sd_sdlkj2982
Content-Length: 262144             <- Chunk size: 256 KB (must be multiple of 256 KB)
Content-Range: bytes 0-262143/2000000000  <- bytes start-end/total
 
[256 KB binary data]
 
HTTP/1.1 308 Resume Incomplete     <- Not 200/201 yet - more chunks needed
Range: 0-262143                    <- Server confirms receipt of bytes 0 to 262143

Google returns 308 Resume Incomplete for partial chunks. It returns 200 OK or 201 Created only when the LAST chunk completes the upload.

Step 3: Query for Interrupted Upload Offset

After a connection failure:

PUT [resumable_session_uri]
Content-Length: 0
Content-Range: bytes */2000000000   <- * means "I don't know where I am, tell me"
 
HTTP/1.1 308 Resume Incomplete
Range: 0-524287999                  <- Server has received bytes 0 through 524,287,999

The client reads Range header, determines the server has 524,288,000 bytes (500 MB), and resumes from byte 524,288,000.

Google Drive Chunk Size Requirement

Google requires chunks to be a multiple of 256 KB (262,144 bytes) except for the final chunk.

Valid chunk sizes: 256KB, 512KB, 1MB, 2MB, 5MB, 10MB, 50MB, 100MB
Invalid chunk sizes: 100KB, 500KB, 3MB (not multiples of 256KB)

Why 256KB alignment? Google's storage infrastructure uses 256KB blocks.
Aligned chunks map directly to storage blocks. Unaligned chunks require split-writes.

5. AWS S3 Multipart Upload

AWS S3 Multipart Upload is the standard for large files on AWS. It is also used internally by many services (EC2 AMIs, RDS snapshots, Lambda deployment packages).

The Three-Phase Protocol

Phase 1: Initiate

aws s3api create-multipart-upload \
    --bucket my-bucket \
    --key "uploads/large-video.mp4" \
    --content-type "video/mp4"
 
{
    "Bucket": "my-bucket",
    "Key": "uploads/large-video.mp4",
    "UploadId": "VXBsb2FkIElEIGZvciA2aWWpbmcncyBteS1tb3ZpZS5tcDQgdXBsb2Fk"
}

The UploadId identifies this multipart upload. Save it — you need it for all subsequent calls.

Phase 2: Upload Parts

# Each part must be 5 MB to 5 GB. Minimum 5 MB except the last part.
# Parts are numbered 1 through 10,000.
 
aws s3api upload-part \
    --bucket my-bucket \
    --key "uploads/large-video.mp4" \
    --upload-id "VXBsb2FkIElEIGZvciA2aWWpbmcncyBteS1tb3ZpZS5tcDQgdXBsb2Fk" \
    --part-number 1 \
    --body part1.bin
 
{
    "ETag": "\"d8e8fca2dc0f896fd7cb4cb0031ba249\""
}
# Save the ETag for each part - needed for CompleteMultipartUpload

Phase 3: Complete (Assemble the Parts)

aws s3api complete-multipart-upload \
    --bucket my-bucket \
    --key "uploads/large-video.mp4" \
    --upload-id "VXBsb2FkIElEIGZvciA2aWWpbmcncyBteS1tb3ZpZS5tcDQgdXBsb2Fk" \
    --multipart-upload '{
        "Parts": [
            {"PartNumber": 1, "ETag": "d8e8fca2dc0f896fd7cb4cb0031ba249"},
            {"PartNumber": 2, "ETag": "d41d8cd98f00b204e9800998ecf8427e"},
            {"PartNumber": 3, "ETag": "098f6bcd4621d373cade4e832627b4f6"}
        ]
    }'

S3 assembles the parts on the server side. The original parts are deleted. The final file appears as a single object.

Abort if Something Goes Wrong

aws s3api abort-multipart-upload \
    --bucket my-bucket \
    --key "uploads/large-video.mp4" \
    --upload-id "VXBsb2FkIElEIGZvciA2aWWpbmcncyBteS1tb3ZpZS5tcDQgdXBsb2Fk"
# Also set a lifecycle rule: abort incomplete multipart uploads older than 7 days
# to prevent orphaned parts accumulating storage costs

S3 Presigned URLs for Direct Browser Upload

This pattern is how every modern file upload to S3 works. The browser uploads directly to S3, bypassing your application server entirely.

@RestController
public class UploadController {
 
    private final S3Presigner s3Presigner;
 
    /**
     * Client calls this to get a presigned URL.
     * Client then uploads directly to S3 using that URL.
     * Your Spring Boot server never handles the file bytes.
     */
    @PostMapping("/upload/initiate")
    public PresignedUploadResponse initiateUpload(
            @RequestBody InitiateUploadRequest request) {
 
        // Create multipart upload
        CreateMultipartUploadRequest createRequest = CreateMultipartUploadRequest.builder()
            .bucket("my-upload-bucket")
            .key("uploads/" + UUID.randomUUID() + "/" + request.getFileName())
            .contentType(request.getContentType())
            .build();
 
        CreateMultipartUploadResponse createResponse = s3Client.createMultipartUpload(createRequest);
        String uploadId = createResponse.uploadId();
        String s3Key = createResponse.key();
 
        // Generate presigned URLs for each part
        int numParts = (int) Math.ceil((double) request.getFileSizeBytes() / (5L * 1024 * 1024));
        List<String> presignedUrls = new ArrayList<>();
 
        for (int partNumber = 1; partNumber <= numParts; partNumber++) {
            UploadPartPresignRequest presignRequest = UploadPartPresignRequest.builder()
                .signatureDuration(Duration.ofHours(1))
                .uploadPartRequest(r -> r
                    .bucket("my-upload-bucket")
                    .key(s3Key)
                    .uploadId(uploadId)
                    .partNumber(partNumber)
                )
                .build();
 
            presignedUrls.add(s3Presigner.presignUploadPart(presignRequest).url().toString());
        }
 
        return PresignedUploadResponse.builder()
            .uploadId(uploadId)
            .s3Key(s3Key)
            .presignedUrls(presignedUrls)  // One URL per part
            .build();
    }
 
    /**
     * After client uploads all parts, call this to complete the multipart upload.
     */
    @PostMapping("/upload/complete")
    public ResponseEntity<Void> completeUpload(
            @RequestBody CompleteUploadRequest request) {
 
        List<CompletedPart> parts = request.getParts().stream()
            .map(p -> CompletedPart.builder()
                .partNumber(p.getPartNumber())
                .eTag(p.getEtag())
                .build()
            )
            .toList();
 
        s3Client.completeMultipartUpload(r -> r
            .bucket("my-upload-bucket")
            .key(request.getS3Key())
            .uploadId(request.getUploadId())
            .multipartUpload(mp -> mp.parts(parts))
        );
 
        return ResponseEntity.ok().build();
    }
}

6. Dropbox Upload Sessions

Dropbox uses a session-based chunked upload for files larger than 150 MB.

Protocol Flow

Step 1: Start a session (first chunk)
POST /2/files/upload_session/start

Step 2: Append chunks
POST /2/files/upload_session/append_v2

Step 3: Finish the session (last chunk + file metadata)
POST /2/files/upload_session/finish
POST https://content.dropboxapi.com/2/files/upload_session/start
Authorization: Bearer [token]
Dropbox-API-Arg: {"close": false}
Content-Type: application/octet-stream
 
[First 150 MB chunk]
 
Response:
{
    "session_id": "1234faaf0678bcde"
}
POST https://content.dropboxapi.com/2/files/upload_session/append_v2
Authorization: Bearer [token]
Dropbox-API-Arg: {
    "cursor": {
        "session_id": "1234faaf0678bcde",
        "offset": 157286400          <- byte offset where this chunk starts
    },
    "close": false                   <- set true on last chunk before finish
}
Content-Type: application/octet-stream
 
[Next 150 MB chunk]
POST https://content.dropboxapi.com/2/files/upload_session/finish
Authorization: Bearer [token]
Dropbox-API-Arg: {
    "cursor": {
        "session_id": "1234faaf0678bcde",
        "offset": 314572800
    },
    "commit": {
        "path": "/path/to/video.mp4",
        "mode": "add",
        "autorename": true,
        "mute": false
    }
}
Content-Type: application/octet-stream
 
[Final remaining bytes]

Resume after interruption:

The session_id is stored on the client. The offset in the cursor tells Dropbox where each chunk starts. If a chunk upload fails, the client re-sends it from the same offset. Dropbox ignores duplicate data if you re-send an already-received range.


7. YouTube Large Video Upload

YouTube handles billions of video uploads. Their system has several unique characteristics.

YouTube's Upload Architecture

Client (browser/mobile app)
    |
    | HTTPS upload
    v
Google Frontend (GFE) - Load Balancer + SSL Termination
    |
    | streamed
    v
Upload Server (receives, validates, checksums)
    |
    | stored
    v
Google Colossus (distributed filesystem) - Temporary storage
    |
    | triggers
    v
Transcoding Pipeline (generates 144p, 240p, 360p, 480p, 720p, 1080p, 4K)
    |
    v
YouTube CDN (Globally distributed delivery)

YouTube Upload Protocol Details

YouTube uses Google's Resumable Upload API (covered in Section 4) with additional YouTube-specific metadata.

Key technical details:

  1. Chunk size: YouTube recommends 256 KB to 64 MB chunks. Default is 8 MB.

  2. Immediate processing of received chunks: YouTube does not wait for all chunks before starting transcoding. As soon as enough data is received (first few minutes of video), YouTube begins generating the 360p version. This is why a YouTube video appears "processing in HD" but 360p is available quickly.

  3. Checksum per chunk: YouTube computes an MD5 hash of each chunk and includes it in the request. If the hash does not match on the server side, the chunk is rejected and the client re-sends.

  4. Adaptive bitrate consideration: When transcoding, YouTube generates multiple quality levels. The upload server notes the original bitrate and estimates transcoding time.

  5. Resumable session TTL: Upload sessions are valid for 6 days. If you start an upload on Monday and your connection drops, you can resume until Sunday.


8. Spring Boot Server: Receiving Chunked Uploads

TUS Protocol Server Implementation in Spring Boot

@RestController
@RequestMapping("/files")
@Slf4j
public class TusUploadController {
 
    private final UploadSessionService uploadSessionService;
    private final StorageService storageService;
 
    /**
     * TUS: Create upload session.
     * Client sends total file size. Server creates a session and returns the upload URL.
     */
    @PostMapping
    public ResponseEntity<Void> createUpload(
            @RequestHeader("Upload-Length") long uploadLength,
            @RequestHeader("Upload-Metadata") String metadata,
            @RequestHeader("Tus-Resumable") String tusVersion) {
 
        validateTusVersion(tusVersion);
 
        if (uploadLength > 5L * 1024 * 1024 * 1024) {  // 5 GB max
            return ResponseEntity.status(HttpStatus.REQUEST_ENTITY_TOO_LARGE).build();
        }
 
        String uploadId = UUID.randomUUID().toString();
        Map<String, String> parsedMetadata = parseMetadata(metadata);
 
        UploadSession session = UploadSession.builder()
            .uploadId(uploadId)
            .totalLength(uploadLength)
            .offset(0L)
            .filename(parsedMetadata.get("filename"))
            .contentType(parsedMetadata.getOrDefault("filetype", "application/octet-stream"))
            .createdAt(Instant.now())
            .expiresAt(Instant.now().plus(7, ChronoUnit.DAYS))
            .build();
 
        uploadSessionService.save(session);
 
        return ResponseEntity.created(URI.create("/files/" + uploadId))
            .header("Tus-Resumable", "1.0.0")
            .header("Upload-Expires", session.getExpiresAt().toString())
            .build();
    }
 
    /**
     * TUS: Query upload offset (for resume).
     */
    @RequestMapping(value = "/{uploadId}", method = RequestMethod.HEAD)
    public ResponseEntity<Void> getUploadOffset(@PathVariable String uploadId) {
        UploadSession session = uploadSessionService.findById(uploadId)
            .orElseThrow(() -> new UploadNotFoundException(uploadId));
 
        if (session.getExpiresAt().isBefore(Instant.now())) {
            return ResponseEntity.status(HttpStatus.GONE)
                .header("Tus-Resumable", "1.0.0")
                .build();
        }
 
        return ResponseEntity.ok()
            .header("Tus-Resumable", "1.0.0")
            .header("Upload-Offset", String.valueOf(session.getOffset()))
            .header("Upload-Length", String.valueOf(session.getTotalLength()))
            .header("Cache-Control", "no-store")  // Must not be cached
            .build();
    }
 
    /**
     * TUS: Receive a file chunk.
     * The chunk starts at Upload-Offset bytes from the beginning of the file.
     */
    @PatchMapping(
        value = "/{uploadId}",
        consumes = "application/offset+octet-stream"
    )
    public ResponseEntity<Void> uploadChunk(
            @PathVariable String uploadId,
            @RequestHeader("Upload-Offset") long uploadOffset,
            @RequestHeader("Content-Length") long contentLength,
            HttpServletRequest request) throws IOException {
 
        UploadSession session = uploadSessionService.findById(uploadId)
            .orElseThrow(() -> new UploadNotFoundException(uploadId));
 
        // Validate offset matches session state
        if (session.getOffset() != uploadOffset) {
            log.warn("Offset mismatch. Expected {}, got {}. uploadId={}",
                session.getOffset(), uploadOffset, uploadId);
            return ResponseEntity.status(HttpStatus.CONFLICT)
                .header("Upload-Offset", String.valueOf(session.getOffset()))
                .build();
        }
 
        // Stream chunk directly to storage (no memory buffering of entire chunk)
        long bytesWritten = storageService.appendChunk(
            uploadId,
            request.getInputStream(),   // Stream directly - no byte[] buffer
            uploadOffset,
            contentLength
        );
 
        long newOffset = uploadOffset + bytesWritten;
        session.setOffset(newOffset);
        uploadSessionService.save(session);
 
        // Check if upload is complete
        if (newOffset >= session.getTotalLength()) {
            session.setStatus(UploadStatus.COMPLETE);
            uploadSessionService.save(session);
 
            // Trigger post-upload processing
            fileProcessingService.processCompletedUpload(uploadId, session);
 
            return ResponseEntity.noContent()
                .header("Tus-Resumable", "1.0.0")
                .header("Upload-Offset", String.valueOf(newOffset))
                .build();
        }
 
        return ResponseEntity.noContent()
            .header("Tus-Resumable", "1.0.0")
            .header("Upload-Offset", String.valueOf(newOffset))
            .build();
    }
 
    /**
     * Parse TUS metadata header.
     * Format: "key1 base64value1,key2 base64value2"
     */
    private Map<String, String> parseMetadata(String metadata) {
        if (metadata <mark class="obsidian-highlight"> null || metadata.isBlank()) return Map.of();
 
        return Arrays.stream(metadata.split(","))
            .map(String::trim)
            .map(pair -> pair.split(" ", 2))
            .filter(parts -> parts.length </mark> 2)
            .collect(Collectors.toMap(
                parts -> parts[0],
                parts -> new String(Base64.getDecoder().decode(parts[1]))
            ));
    }
}

Storage Service: Streaming Chunks to Disk Without Memory Buffering

@Service
@Slf4j
public class FileStorageService {
 
    private final Path uploadDirectory;
 
    public FileStorageService(@Value("${upload.dir:/tmp/uploads}") String uploadDir) {
        this.uploadDirectory = Paths.get(uploadDir);
    }
 
    /**
     * Append a chunk to the file at the given offset.
     * Uses NIO FileChannel with position-based write.
     * Does NOT buffer the entire chunk in memory.
     * Writes stream bytes directly from HTTP request to disk.
     *
     * Returns the number of bytes actually written.
     */
    public long appendChunk(
            String uploadId,
            InputStream chunkStream,
            long offset,
            long expectedLength) throws IOException {
 
        Path filePath = uploadDirectory.resolve(uploadId + ".partial");
        Files.createDirectories(filePath.getParent());
 
        long bytesWritten = 0L;
        byte[] buffer = new byte[64 * 1024];  // 64 KB read buffer (small, not the whole chunk)
 
        try (FileChannel channel = FileChannel.open(filePath,
                StandardOpenOption.CREATE,
                StandardOpenOption.WRITE,
                StandardOpenOption.SPARSE)) {
 
            channel.position(offset);  // Seek to the correct position in the file
 
            int bytesRead;
            while ((bytesRead = chunkStream.read(buffer)) != -1) {
                ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, 0, bytesRead);
                channel.write(byteBuffer);
                bytesWritten += bytesRead;
            }
        }
 
        log.debug("Wrote {} bytes at offset {} for uploadId={}", bytesWritten, offset, uploadId);
        return bytesWritten;
    }
 
    /**
     * Finalize the upload: rename from .partial to the final filename.
     */
    public Path finalizeUpload(String uploadId, String finalFilename) throws IOException {
        Path partialPath = uploadDirectory.resolve(uploadId + ".partial");
        Path finalPath = uploadDirectory.resolve(finalFilename);
 
        Files.move(partialPath, finalPath, StandardCopyOption.ATOMIC_MOVE);
        log.info("Upload finalized: {} -> {}", uploadId, finalFilename);
        return finalPath;
    }
}

Spring Boot Configuration for Large Uploads

# application.yml
spring:
  servlet:
    multipart:
      max-file-size: 10GB
      max-request-size: 10GB
      file-size-threshold: 10MB # Buffer below this size, stream above
 
server:
  tomcat:
    # Allow large request bodies
    max-http-form-post-size: 10GB
    # Streaming timeout: must be long enough for the largest chunk
    connection-timeout: 600000 # 10 minutes: time to establish connection
    keep-alive-timeout: 600000 # Keep connection alive during chunk upload
  # Spring Boot async request timeout
  servlet:
    async-request-timeout: 600000 # 10 minutes for async requests
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
 
    /**
     * Disable default max upload size for TUS upload endpoints.
     * TUS controller handles size validation itself.
     */
    @Bean
    public MultipartConfigElement multipartConfigElement() {
        MultipartConfigFactory factory = new MultipartConfigFactory();
        factory.setMaxFileSize(DataSize.ofGigabytes(10));
        factory.setMaxRequestSize(DataSize.ofGigabytes(10));
        return factory.createMultipartConfig();
    }
 
    /**
     * Increase Tomcat connector timeout for upload endpoints.
     */
    @Bean
    public TomcatServletWebServerFactory tomcatCustomizer() {
        TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
        factory.addConnectorCustomizers(connector -> {
            connector.setProperty("connectionTimeout", "600000");      // 10 min
            connector.setProperty("disableUploadTimeout", "false");
            connector.setProperty("connectionUploadTimeout", "600000"); // 10 min
        });
        return factory;
    }
}

9. Handling Timeouts and Intermittent Connectivity

The Five Types of Upload Failures

Type 1: TCP connection drop (most common)

  • Mobile network switches cell tower
  • WiFi drops for 3 seconds
  • VPN reconnects
  • Resolution: Detect via SocketException or timeout. Read server offset via HEAD. Resume.

Type 2: Server timeout during chunk upload

  • Chunk upload takes longer than server's idle timeout
  • Server closes connection and returns 408 Request Timeout
  • Resolution: Use smaller chunks. Configure server timeout appropriately.

Type 3: Client network going away (browser tab closed, app killed)

  • Upload was in progress. App is killed.
  • Session state must be persisted by the client (IndexedDB in browser, SQLite in mobile).
  • Resolution: On app restart, check for incomplete uploads, resume from offset.

Type 4: Server-side storage failure

  • Server disk full, storage service unavailable
  • Server returns 500 Internal Server Error
  • Resolution: Server should return 5xx with Retry-After header. Client retries with exponential backoff.

Type 5: Concurrent upload from different devices

  • User starts uploading the same file from phone and laptop simultaneously
  • Both try to append to the same upload session
  • Resolution: Session-level locking. One device holds the session at a time. The second device receives 423 Locked.

Retry Strategy with Exponential Backoff

/**
 * Upload manager that handles retries, backoff, and adaptive chunk sizing.
 * This is the client-side logic used by upload applications.
 */
public class ResumableUploadManager {
 
    private static final int MAX_RETRIES = 5;
    private static final long BASE_BACKOFF_MS = 1000;  // 1 second
    private static final long MAX_BACKOFF_MS = 60_000; // 60 seconds
 
    /**
     * Upload a chunk with retry logic and exponential backoff.
     *
     * @param uploadId   The TUS session ID
     * @param data       Bytes of this chunk
     * @param offset     Byte offset where this chunk starts
     * @param totalSize  Total file size
     */
    public void uploadChunkWithRetry(
            String uploadId,
            byte[] data,
            long offset,
            long totalSize) throws UploadException {
 
        int attempt = 0;
        long backoffMs = BASE_BACKOFF_MS;
 
        while (attempt < MAX_RETRIES) {
            try {
                // Attempt the chunk upload
                HttpResponse response = httpClient.patch(
                    "/files/" + uploadId,
                    data,
                    Map.of(
                        "Content-Type",   "application/offset+octet-stream",
                        "Content-Length", String.valueOf(data.length),
                        "Upload-Offset",  String.valueOf(offset),
                        "Tus-Resumable",  "1.0.0"
                    )
                );
 
                if (response.getStatusCode() <mark class="obsidian-highlight"> 204) {
                    // Success: chunk accepted
                    return;
                }
 
                if (response.getStatusCode() </mark> 409) {
                    // Conflict: offset mismatch.
                    // Server received a different amount than we expect.
                    // Query the actual offset and adjust.
                    long serverOffset = queryServerOffset(uploadId);
                    if (serverOffset == offset + data.length) {
                        // Server already has this chunk! Skip it.
                        return;
                    }
                    // Offset genuinely wrong - adjust and retry
                    offset = serverOffset;
                    // Trim data to only the bytes server needs
                }
 
                if (response.getStatusCode() >= 500) {
                    // Server error - retry with backoff
                    log.warn("Server error {} uploading chunk. Attempt {}/{}",
                        response.getStatusCode(), attempt + 1, MAX_RETRIES);
                }
 
            } catch (SocketTimeoutException | ConnectException e) {
                // Network error - retry with backoff
                log.warn("Network error uploading chunk (attempt {}/{}): {}",
                    attempt + 1, MAX_RETRIES, e.getMessage());
 
                // Verify offset before retrying (server may have received partial data)
                try {
                    long serverOffset = queryServerOffset(uploadId);
                    if (serverOffset >= offset + data.length) {
                        // Server got the data despite our connection failure
                        return;
                    }
                    offset = serverOffset;  // Resume from where server actually is
                } catch (Exception ex) {
                    log.warn("Could not verify server offset: {}", ex.getMessage());
                }
            }
 
            attempt++;
            if (attempt < MAX_RETRIES) {
                // Exponential backoff with jitter
                long jitter = (long)(Math.random() * backoffMs * 0.3);
                long sleepMs = Math.min(backoffMs + jitter, MAX_BACKOFF_MS);
                log.info("Waiting {}ms before retry {}/{}", sleepMs, attempt + 1, MAX_RETRIES);
 
                try { Thread.sleep(sleepMs); }
                catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new UploadException("Interrupted", ie); }
 
                backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);  // Double the wait each time
            }
        }
 
        throw new UploadException("Failed after " + MAX_RETRIES + " attempts for uploadId=" + uploadId);
    }
 
    private long queryServerOffset(String uploadId) {
        HttpResponse response = httpClient.head("/files/" + uploadId,
            Map.of("Tus-Resumable", "1.0.0")
        );
        String offsetHeader = response.getHeader("Upload-Offset");
        return offsetHeader != null ? Long.parseLong(offsetHeader) : 0L;
    }
}

Server-Side Timeout Configuration

# nginx.conf: Upstream timeout configuration for upload proxy
location /files {
    proxy_pass http://upload_backend;
 
    # Read timeout: how long to wait for the backend to read a chunk from the client
    # Must be longer than the time to receive the largest chunk
    proxy_read_timeout 600s;       # 10 minutes
 
    # Send timeout: how long to wait between sends from the proxy to the backend
    proxy_send_timeout 600s;
 
    # The maximum size of the client request body
    client_max_body_size 0;        # 0 = unlimited
 
    # Buffer settings: stream chunks, do not buffer entire body in nginx
    proxy_request_buffering off;   # Do not buffer request body in nginx
    proxy_buffering off;           # Do not buffer response from backend
 
    # TCP keepalive to prevent idle connection timeout
    proxy_socket_keepalive on;
}

10. Resumable Downloads: HTTP Range Requests

What Range Requests Are

HTTP Range Requests allow a client to request only a portion of a resource. This is the mechanism behind:

  • Resumable downloads (resume from where you left off)
  • Video seeking (jump to the 10-minute mark without downloading from the start)
  • Parallel downloads (download different parts of the file simultaneously)
  • Partial content delivery (CDN serving specific chunks)

How It Works

Client signals support:

GET /files/large-video.mp4 HTTP/1.1
Range: bytes=0-                    <- "Give me everything from byte 0 onwards"

Server confirms it supports range requests:

HTTP/1.1 200 OK
Accept-Ranges: bytes               <- This header says "yes, I support range requests"
Content-Length: 2147483648
Content-Type: video/mp4

Client downloads the first part, connection drops.

Client resumes from where it stopped:

GET /files/large-video.mp4 HTTP/1.1
Range: bytes=524288000-            <- "Give me from byte 524,288,000 to the end"

Server responds with partial content:

HTTP/1.1 206 Partial Content       <- 206, not 200: "Here is a part, not all"
Content-Range: bytes 524288000-2147483647/2147483648
                    ^                               ^
                    Start byte                      Total file size (after /)
Content-Length: 1623195648         <- Size of THIS response (total - offset)
Content-Type: video/mp4
Accept-Ranges: bytes
 
[1.5 GB of video data starting from byte 524,288,000]

Range Request Syntax

Range: bytes=0-499          <- First 500 bytes
Range: bytes=500-999        <- Bytes 500 through 999 (inclusive on both ends)
Range: bytes=9500-          <- From byte 9500 to end of file
Range: bytes=-500           <- Last 500 bytes
Range: bytes=0-0,-1         <- First byte AND last byte (multi-range, uncommon)

Server Status Codes

200 OK               - Full file, range not used (server does not support ranges)
206 Partial Content  - Partial file returned (range request honored)
416 Range Not Satisfiable - Range is invalid (e.g., start > file size)
                            Response includes: Content-Range: bytes */2147483648

11. Spring Boot Server: Serving Resumable Downloads

Method 1: ResourceHttpRequestHandler (Built-in, Simplest)

Spring Boot's ResourceHttpRequestHandler supports range requests automatically for static files.

@Configuration
public class StaticFileConfig implements WebMvcConfigurer {
 
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/downloads/**")
            .addResourceLocations("file:/opt/downloads/")
            .setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS));
        // Range requests (206 Partial Content) are handled automatically!
    }
}

For files served this way, Spring MVC handles Range headers and sends 206 Partial Content responses automatically. You do not need to write any custom code.

Method 2: StreamingResponseBody with Range Support (Custom Logic)

For dynamically generated content or database BLOBs:

@RestController
@RequestMapping("/files")
@Slf4j
public class FileDownloadController {
 
    private final FileMetadataService fileMetadataService;
    private final FileStorageService storageService;
 
    /**
     * Serve a file with full Range request support.
     * Supports: full download, partial download (resume), video seeking.
     */
    @GetMapping("/{fileId}")
    public ResponseEntity<StreamingResponseBody> downloadFile(
            @PathVariable String fileId,
            @RequestHeader(value = "Range", required = false) String rangeHeader,
            HttpServletResponse response) {
 
        FileMetadata metadata = fileMetadataService.findById(fileId)
            .orElseThrow(() -> new FileNotFoundException(fileId));
 
        long fileSize = metadata.getSizeBytes();
 
        // Parse the Range header
        Range range = parseRange(rangeHeader, fileSize);
 
        long start  = range.start;
        long end    = range.end;
        long length = end - start + 1;  // Number of bytes to return
 
        HttpStatus status;
        if (rangeHeader != null) {
            status = HttpStatus.PARTIAL_CONTENT;  // 206
        } else {
            status = HttpStatus.OK;               // 200 (no range specified)
        }
 
        // Build the response body as a stream
        StreamingResponseBody body = outputStream -> {
            try (InputStream fileStream = storageService.openStream(fileId, start)) {
                byte[] buffer = new byte[64 * 1024];  // 64 KB read buffer
                long remaining = length;
 
                int bytesRead;
                while (remaining > 0 &&
                       (bytesRead = fileStream.read(buffer, 0, (int) Math.min(buffer.length, remaining))) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                    remaining -= bytesRead;
                    outputStream.flush();  // Flush periodically for streaming
                }
            } catch (IOException e) {
                // Client disconnected mid-download - log and stop (not an error)
                if (e.getMessage() != null && e.getMessage().contains("Broken pipe")) {
                    log.debug("Client disconnected during download of {}", fileId);
                } else {
                    log.error("Error streaming file {}: {}", fileId, e.getMessage());
                }
            }
        };
 
        return ResponseEntity.status(status)
            .header("Content-Type",        metadata.getContentType())
            .header("Content-Length",      String.valueOf(length))
            .header("Content-Disposition", "attachment; filename=\"" + metadata.getFilename() + "\"")
            .header("Accept-Ranges",       "bytes")  // Advertise range support
            .header("Content-Range",       rangeHeader != null
                ? "bytes " + start + "-" + end + "/" + fileSize
                : null)
            .header("ETag",                metadata.getEtag())  // For cache validation
            .header("Last-Modified",       metadata.getLastModified().toString())
            .body(body);
    }
 
    private Range parseRange(String rangeHeader, long fileSize) {
        if (rangeHeader == null || !rangeHeader.startsWith("bytes=")) {
            // No range: return entire file
            return new Range(0, fileSize - 1);
        }
 
        String rangeValue = rangeHeader.substring(6);  // Remove "bytes="
        String[] parts = rangeValue.split("-", 2);
 
        long start;
        long end;
 
        if (parts[0].isEmpty()) {
            // Suffix range: bytes=-500 means last 500 bytes
            long suffix = Long.parseLong(parts[1]);
            start = Math.max(0, fileSize - suffix);
            end = fileSize - 1;
        } else {
            start = Long.parseLong(parts[0]);
            end = parts[1].isEmpty() ? fileSize - 1 : Long.parseLong(parts[1]);
        }
 
        // Validate range
        if (start > end || start >= fileSize || end >= fileSize) {
            throw new InvalidRangeException(
                "Range " + rangeHeader + " invalid for file size " + fileSize
            );
        }
 
        return new Range(start, Math.min(end, fileSize - 1));
    }
 
    record Range(long start, long end) {}
}

Handling 416 Range Not Satisfiable

@ExceptionHandler(InvalidRangeException.class)
public ResponseEntity<Void> handleInvalidRange(
        InvalidRangeException ex,
        HttpServletRequest request) {
 
    String fileId = extractFileId(request);
    FileMetadata metadata = fileMetadataService.findById(fileId).orElse(null);
    long fileSize = metadata != null ? metadata.getSizeBytes() : 0;
 
    return ResponseEntity.status(HttpStatus.REQUESTED_RANGE_NOT_SATISFIABLE)
        .header("Content-Range", "bytes */" + fileSize)
        .build();
}

Checksum Verification for Resumed Downloads

/**
 * On a resumed download, the client must verify that the bytes
 * it received before the interruption match what the server has.
 * If they do not match (file was updated), restart from zero.
 *
 * Mechanism: ETag + conditional request headers.
 */
@GetMapping("/{fileId}")
public ResponseEntity<StreamingResponseBody> downloadFile(
        @PathVariable String fileId,
        @RequestHeader(value = "Range", required = false) String rangeHeader,
        @RequestHeader(value = "If-Range", required = false) String ifRange) {
 
    FileMetadata metadata = fileMetadataService.findById(fileId).orElseThrow();
 
    // If-Range: client sends the ETag of the file it started downloading.
    // If the ETag matches (file hasn't changed): honor the Range request (206).
    // If the ETag does NOT match (file changed): send the whole new file (200).
    if (ifRange != null && !ifRange.equals(metadata.getEtag())) {
        rangeHeader = null;  // Ignore Range, serve full file
        log.info("If-Range mismatch for {}. File changed. Serving full file.", fileId);
    }
 
    // ... rest of the logic with rangeHeader (now null if file changed)
}

12. CDN and Partial Content Delivery

How CDN Handles Range Requests

Client browser requests bytes 50MB-100MB of a video file
    |
    v
CDN Edge Server (e.g., Cloudflare, CloudFront, Akamai)

CASE 1: CDN has the entire file cached
    -> CDN slices bytes 50MB-100MB from its cache
    -> Returns 206 Partial Content directly
    -> Origin server is not contacted. Zero origin load.

CASE 2: CDN has part of the file cached (e.g., has bytes 0-30MB)
    -> CDN needs to fetch bytes 50MB-100MB from origin
    -> CDN sends a Range request to origin: Range: bytes=50000000-104857599
    -> Origin returns 206. CDN caches this range.
    -> CDN returns 206 to client.

CASE 3: CDN has nothing cached for this file
    -> CDN fetches the entire file from origin (or a large chunk of it)
    -> CDN caches it
    -> CDN returns the requested range to client

Setting Cache Headers for Downloadable Files

@GetMapping("/{fileId}")
public ResponseEntity<StreamingResponseBody> downloadFile(@PathVariable String fileId) {
    FileMetadata metadata = fileMetadataService.findById(fileId).orElseThrow();
 
    return ResponseEntity.ok()
        .header("ETag",            "\"" + metadata.getEtag() + "\"")
        .header("Last-Modified",   formatHttpDate(metadata.getLastModified()))
        .header("Accept-Ranges",   "bytes")
 
        // Cache-Control for static/versioned files (safe to cache aggressively)
        .header("Cache-Control", "public, max-age=86400, immutable")
        //                        ^                    ^ File does not change
        //                        ^ CDN can cache it
 
        // For user-specific or sensitive files: no CDN caching
        // .header("Cache-Control", "private, max-age=0, no-store")
 
        .body(streamingBody);
}

Nginx Configuration for Resumable Downloads

server {
    location /downloads/ {
        alias /opt/downloads/;
 
        # Enable range requests (on by default in nginx, but explicit is clear)
        # nginx supports Range requests natively for static files
 
        # Send file in chunks (non-blocking file I/O)
        sendfile on;
        tcp_nopush on;      # Accumulate data before sending (efficiency)
        tcp_nodelay on;     # But send immediately when not using tcp_nopush
 
        # Cache headers
        add_header Accept-Ranges bytes;
        add_header Cache-Control "public, max-age=86400";
 
        # Limit rate per connection (fair bandwidth distribution)
        limit_rate 50m;     # 50 MB/s per connection
 
        # Timeout configuration
        send_timeout 600s;  # 10 minutes: time between writes to client
    }
}

13. Client-Side Resume Logic (JavaScript)

Browser-Based Resumable Upload with TUS

class ResumableUploader {
  constructor(file, uploadEndpoint) {
    this.file = file;
    this.endpoint = uploadEndpoint;
    this.chunkSize = 5 * 1024 * 1024; // 5 MB default
    this.uploadId = null;
    this.offset = 0;
  }
 
  /**
   * Start or resume an upload.
   * Persists upload state in IndexedDB for true resume across browser restarts.
   */
  async upload(onProgress) {
    // Check if we have a persisted upload ID for this file
    this.uploadId = await this.getSavedUploadId(this.file.name, this.file.size);
 
    if (this.uploadId) {
      // Resume: query server for current offset
      this.offset = await this.queryServerOffset(this.uploadId);
      console.log(
        `Resuming upload ${this.uploadId} from offset ${this.offset}`,
      );
    } else {
      // New upload: create session on server
      this.uploadId = await this.createUploadSession();
      await this.saveUploadId(this.file.name, this.file.size, this.uploadId);
      this.offset = 0;
    }
 
    // Upload chunks from current offset
    while (this.offset < this.file.size) {
      const chunk = this.file.slice(this.offset, this.offset + this.chunkSize);
      await this.uploadChunkWithRetry(chunk);
 
      onProgress((this.offset / this.file.size) * 100);
    }
 
    // Clean up persisted state
    await this.clearSavedUploadId(this.file.name, this.file.size);
    console.log("Upload complete!");
  }
 
  async createUploadSession() {
    const response = await fetch(this.endpoint, {
      method: "POST",
      headers: {
        "Upload-Length": this.file.size,
        "Upload-Metadata": `filename ${btoa(this.file.name)}`,
        "Tus-Resumable": "1.0.0",
        "Content-Length": "0",
      },
    });
 
    if (response.status !== 201)
      throw new Error(`Failed to create session: ${response.status}`);
    return response.headers.get("Location").split("/").pop();
  }
 
  async queryServerOffset(uploadId) {
    const response = await fetch(`${this.endpoint}/${uploadId}`, {
      method: "HEAD",
      headers: { "Tus-Resumable": "1.0.0" },
    });
 
    if (response.status === 410) {
      // Session expired - start fresh
      console.warn("Upload session expired, starting fresh");
      await this.clearSavedUploadId(this.file.name, this.file.size);
      this.uploadId = await this.createUploadSession();
      return 0;
    }
 
    return parseInt(response.headers.get("Upload-Offset") || "0");
  }
 
  async uploadChunkWithRetry(chunk, maxRetries = 5) {
    let attempt = 0;
    let backoff = 1000; // Start with 1 second
 
    while (attempt < maxRetries) {
      try {
        const response = await fetch(`${this.endpoint}/${this.uploadId}`, {
          method: "PATCH",
          headers: {
            "Content-Type": "application/offset+octet-stream",
            "Content-Length": chunk.size,
            "Upload-Offset": this.offset,
            "Tus-Resumable": "1.0.0",
          },
          body: chunk,
          signal: AbortSignal.timeout(60000), // 60 second per-chunk timeout
        });
 
        if (response.status === 204) {
          this.offset += chunk.size;
          this.chunkSize = this.adaptChunkSize(true); // Success: try larger chunks
          return;
        }
 
        if (response.status === 409) {
          // Offset conflict: re-query server
          this.offset = await this.queryServerOffset(this.uploadId);
          return; // The chunk may have been received; check on next iteration
        }
 
        console.warn(`Chunk upload returned ${response.status}, retrying...`);
      } catch (error) {
        if (error.name === "TimeoutError") {
          console.warn("Chunk upload timed out, retrying with smaller chunk");
          this.chunkSize = this.adaptChunkSize(false); // Timeout: smaller chunks
        } else {
          console.warn(`Network error: ${error.message}, retrying...`);
        }
 
        // Re-verify offset before retrying
        try {
          this.offset = await this.queryServerOffset(this.uploadId);
        } catch (e) {
          console.warn(
            "Could not verify offset, will retry chunk from last known offset",
          );
        }
      }
 
      attempt++;
      if (attempt < maxRetries) {
        const jitter = Math.random() * 500;
        await new Promise((resolve) => setTimeout(resolve, backoff + jitter));
        backoff = Math.min(backoff * 2, 60000); // Max 60 seconds
      }
    }
 
    throw new Error(`Failed to upload chunk after ${maxRetries} attempts`);
  }
 
  /**
   * Adaptive chunk sizing.
   * Successful fast uploads: increase chunk size (reduce overhead).
   * Failures or timeouts: decrease chunk size (reduce retry cost).
   */
  adaptChunkSize(success) {
    const MIN = 256 * 1024; // 256 KB minimum
    const MAX = 50 * 1024 * 1024; // 50 MB maximum
    if (success) {
      return Math.min(Math.floor(this.chunkSize * 1.2), MAX);
    } else {
      return Math.max(Math.floor(this.chunkSize * 0.7), MIN);
    }
  }
 
  // IndexedDB persistence for resume across browser restarts
  async getSavedUploadId(filename, filesize) {
    const db = await this.openDb();
    const tx = db.transaction("uploads", "readonly");
    const record = await tx
      .objectStore("uploads")
      .get(`${filename}-${filesize}`);
    return record?.uploadId || null;
  }
 
  async saveUploadId(filename, filesize, uploadId) {
    const db = await this.openDb();
    const tx = db.transaction("uploads", "readwrite");
    tx.objectStore("uploads").put(
      { uploadId, timestamp: Date.now() },
      `${filename}-${filesize}`,
    );
  }
 
  async clearSavedUploadId(filename, filesize) {
    const db = await this.openDb();
    const tx = db.transaction("uploads", "readwrite");
    tx.objectStore("uploads").delete(`${filename}-${filesize}`);
  }
 
  openDb() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open("ResumableUploads", 1);
      request.onupgradeneeded = (e) =>
        e.target.result.createObjectStore("uploads");
      request.onsuccess = (e) => resolve(e.target.result);
      request.onerror = (e) => reject(e.target.error);
    });
  }
}
 
// Usage:
const uploader = new ResumableUploader(fileInput.files[0], "/files");
await uploader.upload((progress) => {
  progressBar.style.width = progress + "%";
  progressLabel.textContent = Math.round(progress) + "%";
});

Summary: Upload Strategy Decision Matrix

File SizeNetworkStrategyProtocol
< 1 MBReliableSimple POSTStandard HTTP multipart
1-100 MBReliableStreaming POSTTransfer-Encoding: chunked
1-100 MBUnreliableResumableTUS or vendor protocol
100 MB+AnyResumable + Direct-to-storageS3 Multipart + Presigned URLs
1 GB+AnyResumable + CDN + DirectS3/GCS Multipart via Presigned, bypass app server entirely

Summary: Download Strategy Decision Matrix

RequirementStrategyHTTP Status
Simple file downloadStandard GET200 OK
Video streaming (seek)Range requests206 Partial Content
Resume interrupted downloadRange requests + ETag206 + If-Range
Large file from CDNRange requests + cache headers206 (from CDN cache)
Parallel download acceleratorMultiple Range requests206 (multiple connections)
Sensitive/authenticated fileSigned URL + no CDN cache206 with Cache-Control: private