Otimizando uploads de arquivos online com chunks e paralelismo
Lidar com uploads de arquivos de forma eficiente é um aspecto essencial das aplicações web modernas. Arquivos grandes podem causar uploads lentos, interrupções de rede e uma experiência ruim para o usuário. Neste post, exploramos técnicas avançadas para otimizar uploads de arquivos, como a divisão em chunks e o upload paralelo, garantindo um desempenho mais rápido e confiável.
Introdução aos desafios do upload de arquivos
Fazer upload de arquivos grandes pela internet traz vários desafios. Os usuários podem enfrentar velocidades de upload baixas por causa de limitações de banda ou instabilidade da rede, e as interrupções muitas vezes obrigam a recomeçar do zero, o que gera frustração. Aplicações web modernas exigem sistemas de upload robustos, capazes de lidar com esses desafios sem comprometer uma experiência fluida para o usuário.
O que é a divisão em chunks e por que ela importa
A divisão em chunks consiste em quebrar um arquivo grande em partes menores, chamadas chunks. Essa abordagem oferece várias vantagens:
- Nova tentativa de chunks individuais, em vez de reiniciar o arquivo inteiro
- Melhor gerenciamento de memória no navegador
- Acompanhamento de progresso mais simples
- Menor impacto de interrupções de rede
- Recuperação de erros mais eficiente
O tamanho ideal de chunk depende do protocolo, da rede e dos limites do servidor. Este exemplo busca no máximo dez chunks para arquivos pequenos e limita cada chunk a 5 MiB como uma escolha da aplicação, não como um limite do navegador. Arquivos vazios são rejeitados explicitamente:
const calculateChunkSize = (fileSize) => {
if (!Number.isSafeInteger(fileSize) || fileSize <= 0) {
throw new Error('Select a nonempty file')
}
const MAXIMUM_CHUNK_SIZE = 1024 * 1024 * 5
return Math.min(MAXIMUM_CHUNK_SIZE, Math.ceil(fileSize / 10))
}
Configurando um upload de arquivos básico com JavaScript
Coloque o HTML primeiro e depois combine as definições de JavaScript abaixo em um único módulo. O exemplo exige endpoints no servidor que implementem o protocolo personalizado descrito na próxima seção; ele não é um cliente pronto para uso com qualquer endpoint de upload de arquivos.
<div id="upload-container">
<label for="file-input">Files to upload</label>
<input type="file" id="file-input" multiple />
<button id="upload-btn">Upload</button>
<button id="cancel-btn">Cancel</button>
<p id="progress" role="status"></p>
</div>
class FileUploader {
constructor() {
this.abortController = null
this.setupEventListeners()
}
setupEventListeners() {
const uploadBtn = document.getElementById('upload-btn')
const cancelBtn = document.getElementById('cancel-btn')
uploadBtn.addEventListener('click', () => this.handleUpload())
cancelBtn.addEventListener('click', () => this.cancelUpload())
}
async handleUpload() {
if (this.abortController) return
const fileInput = document.getElementById('file-input')
const files = fileInput.files
if (files.length === 0) {
document.getElementById('progress').textContent = 'Please select a file.'
return
}
this.abortController = new AbortController()
try {
for (const file of files) {
await this.uploadFile(file)
}
document.getElementById('progress').textContent = 'Upload complete.'
} catch (error) {
document.getElementById('progress').textContent =
error.name === 'AbortError' ? 'Upload canceled.' : 'Upload failed. Please try again.'
} finally {
this.abortController = null
}
}
async uploadFile(file) {
const upload = new SecureUploader(file)
await upload.upload(this.abortController.signal, (percent) => {
this.updateProgress(file, percent)
})
}
cancelUpload() {
if (this.abortController) {
this.abortController.abort()
}
}
updateProgress(file, percentage) {
const progress = document.getElementById('progress')
progress.textContent = `${file.name}: ${Math.round(percentage)}%`
}
}
const uploader = new FileUploader()
Implementando uploads em chunks
O servidor precisa autenticar cada requisição, autorizar o uploadId para esse usuário, impor limites de tamanho
e de quantidade de chunks, e armazenar os chunks de forma idempotente por (uploadId, chunkNumber). A finalização precisa
verificar todos os chunks e a ordem deles antes de publicar o arquivo. Não use fileName como caminho no
sistema de arquivos. Faça os uploads incompletos expirarem no servidor. Este protocolo didático faz
novas tentativas dentro de uma sessão da página; ele não implementa recuperação após recarregar a
página nem um backend completo.
Cada requisição abaixo espera um status HTTP de sucesso; nenhum corpo de resposta JSON é necessário.
class ChunkedUploader {
constructor(file, options = {}) {
this.file = file
this.uploadId = crypto.randomUUID()
this.chunkSize = calculateChunkSize(file.size)
this.totalChunks = Math.ceil(file.size / this.chunkSize)
this.retryLimit = options.retryLimit ?? 3
this.retryDelay = options.retryDelay ?? 1000
this.concurrency = options.concurrency ?? 3
if (![this.retryLimit, this.concurrency].every((value) => Number.isInteger(value) && value > 0)
|| !Number.isFinite(this.retryDelay) || this.retryDelay < 0) {
throw new Error('Invalid upload options')
}
}
async uploadChunk(chunk, chunkNumber, signal) {
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('fileName', this.file.name)
formData.append('uploadId', this.uploadId)
formData.append('chunkNumber', chunkNumber)
formData.append('totalChunks', this.totalChunks)
let attempts = 0
while (attempts < this.retryLimit) {
signal.throwIfAborted()
try {
const response = await fetch('/upload-chunk', {
method: 'POST',
body: formData,
signal,
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
return
} catch (error) {
signal.throwIfAborted()
attempts++
if (attempts === this.retryLimit) throw error
await this.waitForRetry(this.retryDelay * 2 ** (attempts - 1), signal)
}
}
}
waitForRetry(milliseconds, signal) {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer)
reject(signal.reason)
}
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, milliseconds)
signal.addEventListener('abort', onAbort, { once: true })
})
}
async upload(signal, onProgress) {
let uploadedBytes = 0
// Upload chunks with a concurrency limit
for (let i = 0; i < this.totalChunks; i += this.concurrency) {
signal.throwIfAborted()
const requests = []
for (let number = i; number < Math.min(i + this.concurrency, this.totalChunks); number++) {
const chunk = this.file.slice(number * this.chunkSize, (number + 1) * this.chunkSize)
requests.push(this.uploadChunk(chunk, number, signal).then(() => {
uploadedBytes += chunk.size
onProgress?.(uploadedBytes / this.file.size * 100)
}))
}
// Settle in-flight requests before reporting failure or allowing another upload.
const results = await Promise.allSettled(requests)
const failure = results.find((result) => result.status === 'rejected')
if (failure) throw failure.reason
}
// Notify server that all chunks are uploaded
const response = await fetch('/complete-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fileName: this.file.name,
uploadId: this.uploadId,
totalChunks: this.totalChunks,
}),
signal,
})
if (!response.ok) throw new Error(`Finalization failed: HTTP ${response.status}`)
}
}
Upload paralelo: mais velocidade
A abordagem acima aproveita o upload paralelo ao processar vários chunks simultaneamente. Essa técnica pode melhorar a taxa de transferência quando uma única requisição não satura a conexão. Meça o resultado com o seu servidor e a sua rede: requisições paralelas também adicionam overhead e podem atingir limites de taxa. O callback de progresso conta os bytes confirmados, não os bytes que ainda estão em trânsito.
Tratando erros e novas tentativas
Nossa implementação inclui um tratamento de erros robusto, com novas tentativas automáticas, backoff exponencial e cancelamento controlado usando AbortController. Essa estratégia garante que problemas transitórios de rede ou erros do servidor possam ser repetidos um número limitado de vezes. É recomendável exibir mensagens de erro claras e diferenciar falhas de rede de erros da aplicação, permitindo que os usuários tentem o upload novamente quando necessário.
Garantindo a segurança durante os uploads
A subclasse a seguir executa verificações básicas no lado do cliente antes de enviar qualquer chunk. Elas servem apenas como feedback antecipado: um cabeçalho compatível não prova que um arquivo é seguro, e os clientes podem contornar esse código. O servidor precisa validar o conteúdo e o tamanho de forma independente, autorizar o acesso e aplicar varredura contra malware ou Content Disarm & Reconstruction quando for apropriado.
class SecureUploader extends ChunkedUploader {
async upload(signal, onProgress) {
signal.throwIfAborted()
await this.validateFile()
return super.upload(signal, onProgress)
}
async validateFile() {
// Validate file signature using allowed types
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
if (!allowedTypes.includes(this.file.type)) {
throw new Error('Unsupported file type')
}
const header = new Uint8Array(await this.file.slice(0, 4).arrayBuffer())
if (!this.validateFileSignature(header)) {
throw new Error('Invalid file signature')
}
// Size validation
const maxSize = 100 * 1024 * 1024 // 100 MiB
if (this.file.size > maxSize) {
throw new Error('File too large')
}
}
validateFileSignature(header) {
const signatures = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'application/pdf': [0x25, 0x50, 0x44, 0x46],
}
const signature = signatures[this.file.type]
return signature !== undefined && signature.every((byte, i) => header[i] === byte)
}
}
Outras medidas de segurança incluem:
- Implementar cabeçalhos de Content Security Policy (CSP).
- Utilizar Content Disarm & Reconstruction (CDR) e varredura antivírus.
- Aplicar uma validação rigorosa do tipo e do tamanho dos arquivos.
Boas práticas e dicas de otimização
- Use Web Workers para tarefas intensivas de processamento de arquivos.
- Implemente compressão de arquivos no lado do cliente quando for apropriado.
- Para recuperar o upload após recarregar a página, persista uma URL de upload emitida pelo servidor e reconcilie o offset confirmado; salvar apenas um percentual de progresso não é suficiente.
- Monitore o uso de memória durante uploads grandes.
- Ofereça um feedback visual claro sobre o status do upload.
- Garanta a limpeza adequada dos uploads que falharam.
- Aproveite recursos modernos do navegador, como Service Workers para uploads em segundo plano e ReadableStream para um tratamento eficiente de dados. O tempo de vida de um Service Worker é limitado; ele não garante que um upload continue depois que o navegador for fechado.
Conclusão: construindo sistemas de upload eficientes
Construir um sistema robusto de upload de arquivos exige atenção cuidadosa ao desempenho, à segurança
e à experiência do usuário. As técnicas discutidas oferecem uma base sólida para implementar uploads
de arquivos confiáveis em aplicações web modernas. Aproveitando a divisão em chunks, os uploads
paralelos e as APIs modernas do navegador, você consegue construir sistemas de upload eficientes e
resilientes. Para uma solução pronta para produção que oferece uma interface de upload mantida,
considere o Uppy. O exemplo a seguir é uma alternativa ao uploader personalizado acima, não uma
camada adicional de divisão em chunks. O XHRUpload envia arquivos inteiros; use o plugin tus do Uppy
com um servidor tus compatível quando precisar de uploads retomáveis. Instale @uppy/core, @uppy/dashboard
e @uppy/xhr-upload na sua aplicação empacotada e disponibilize um endpoint /upload autorizado:
import { Uppy } from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import XHRUpload from '@uppy/xhr-upload'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
const uppy = new Uppy()
.use(Dashboard, {
inline: true,
target: '#upload-container',
})
.use(XHRUpload, {
endpoint: '/upload',
formData: true,
fieldName: 'file',
})
Essas abordagens modernas, junto com um tratamento de erros cuidadoso e validações de segurança, vão ajudar você a criar uma experiência de upload de arquivos fluida.
