Acelere uploads de arquivos em JS com Web Workers e streams
Fazer upload de arquivos com eficiência é essencial para aplicações web modernas. Abordagens tradicionais costumam ler arquivos inteiros na memória, o que pode congelar a interface, consumir RAM em excesso e levar a uma experiência ruim para o usuário, principalmente com arquivos grandes. Ao transferir o processamento pesado para Web Workers e fazer streaming dos dados em blocos gerenciáveis com as Streams do JavaScript, você mantém a thread principal responsiva, mesmo quando os usuários enviam arquivos de vários gigabytes.
Desafios dos uploads de arquivos tradicionais
Uma abordagem comum, porém ineficiente, envolve:
- Ler o objeto
Fileinteiro na memória usandoFileReader. - Montar um objeto
FormDatacom os dados do arquivo. - Enviar o
FormDatapor meio de uma requisição POST comfetchouXMLHttpRequest.
Ler arquivos inteiros em buffers da aplicação pode causar grandes picos de memória. Anexar um File
diretamente ao FormData não exige essa leitura: uploads comuns já são assíncronos.
Os workers ajudam quando também é necessário um processamento pesado de CPU, e não aumentando a
largura de banda da rede.
Conheça os Web Workers e as streams
Web Workers
Web Workers permitem executar código JavaScript em threads em segundo plano, separadas da thread de execução principal que cuida da interface. Isso significa que tarefas computacionalmente intensivas, como hashing, compressão ou divisão de dados em blocos para upload, não bloqueiam a renderização, a rolagem nem a entrada do usuário, o que resulta em uma experiência mais fluida durante os uploads de arquivos.
Streams de JavaScript
A Streams API permite processar dados de forma incremental, em blocos. Em vez de carregar um arquivo
inteiro na memória, você pode ler e processar pequenos pedaços (normalmente objetos Uint8Array) à
medida que ficam disponíveis. Isso reduz drasticamente o uso de memória e permite enviar os dados
pela rede quase imediatamente após a leitura, o que torna as streams de JavaScript úteis para
uploads grandes.
Visão geral da arquitetura
Um sistema de upload paralelo que usa essas tecnologias normalmente envolve:
- Thread principal: cuida das interações da interface (como arrastar e soltar), da seleção de
arquivos e da exibição das atualizações de progresso. Ela repassa objetos
Filepara o pool de workers. - Pool de workers: um conjunto de Web Workers gerencia as tarefas de processamento de arquivos.
Cada worker disponível recebe uma referência a
File. - Worker individual: usa a API
Blob.stream()ouFile.stream()para ler o arquivo bloco a bloco. Cada bloco é então enviado via POST para o endpoint de upload do back-end. Mensagens de progresso (porcentagem concluída) e atualizações de status (conclusão, erros) são enviadas de volta à thread principal. - Back-end: recebe os blocos e os remonta no arquivo completo. Isso costuma envolver protocolos como o tus, uploads multipart de armazenamento em nuvem (por exemplo, S3 Multipart Upload) ou lógica personalizada no servidor.
Streaming de um arquivo sem congelar a interface
Os exemplos a seguir demonstram um padrão mínimo, mas prático, para uploads em blocos usando um pool de workers. Observe a inclusão de tratamento de erros e de mecanismos de limpeza.
Thread principal (main.js)
Este script configura o pool de workers e trata os eventos de entrada de arquivos, delegando ao pool
o processamento de cada arquivo. Coloque a definição de WorkerPool, apresentada mais adiante neste
artigo, antes deste código em main.js e carregue esse arquivo depois deste HTML. Sirva os dois
scripts a partir da mesma origem, via localhost ou HTTPS. Os callbacks abaixo registram o progresso
no log; uma interface de produção deve exibi-lo de forma visível.
<label for="file-input">Files to upload</label>
<input type="file" id="file-input" multiple />
<button type="button" id="cancel-uploads">Cancel uploads</button>
<script src="main.js"></script>
// Assumes WorkerPool class is defined elsewhere (see below)
let pool = new WorkerPool('upload-worker.js')
const fileInput = document.querySelector('#file-input')
fileInput.addEventListener('change', (evt) => {
if (pool.closed) pool = new WorkerPool('upload-worker.js')
const files = Array.from(evt.target.files)
files.forEach((file) => {
console.log(`Queueing ${file.name} for upload...`)
pool.processFile(file, {
onProgress: (pct, msg) => updateProgressUI(file.name, pct, msg),
onComplete: (msg) => showSuccess(file.name, msg),
onError: (err) => showError(file.name, err),
})
})
fileInput.value = ''
})
function updateProgressUI(filename, pct, message) {
// Update your progress bar or UI element here
console.log(`${filename}: ${pct.toFixed(1)}% – ${message}`)
}
function showSuccess(filename, message) {
// Update UI to show completion
console.info(`${filename}: ${message}`)
}
function showError(filename, error) {
// Update UI to show error state
console.error(`${filename}: Upload failed - ${error}`)
}
document.getElementById('cancel-uploads').addEventListener('click', () => pool.terminate())
window.addEventListener('pagehide', () => {
pool.terminate()
})
Implementação do worker (upload-worker.js)
Este worker agrupa os blocos de tamanho variável da stream do navegador em requisições de 1 MiB. O
back-end precisa autenticar e autorizar cada uploadId, aceitar blocos de forma idempotente por
índice, impor limites e finalizar somente depois de validar todos os blocos. /upload aceita os
campos multipart abaixo; /complete-upload aceita JSON com uploadId e totalChunks. Ambos devem retornar um
status HTTP de sucesso. Este tutorial não fornece esses endpoints personalizados, e nomes de arquivo
nunca devem ser tratados como caminhos de armazenamento sem verificação. Este exemplo tem timeouts de
requisição, mas não tem recuperação por nova tentativa nem após recarregar a página; use um
protocolo retomável mantido ativamente quando precisar dessas garantias.
const CHUNK_SIZE = 1024 * 1024
async function* readChunks(file) {
const reader = file.stream().getReader()
let buffer = new Uint8Array(CHUNK_SIZE)
let used = 0
let finished = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
finished = true
break
}
let offset = 0
while (offset < value.length) {
const length = Math.min(CHUNK_SIZE - used, value.length - offset)
buffer.set(value.subarray(offset, offset + length), used)
used += length
offset += length
if (used === CHUNK_SIZE) {
yield buffer
buffer = new Uint8Array(CHUNK_SIZE)
used = 0
}
}
}
if (used > 0) yield buffer.subarray(0, used)
} finally {
if (!finished) await reader.cancel().catch(() => {})
reader.releaseLock()
}
}
async function uploadChunk(chunk, filename, index, uploadId, totalChunks) {
const formData = new FormData()
// Send chunk index for server-side reassembly
formData.append('chunkIndex', index.toString())
// Send the actual chunk data as a Blob
formData.append('fileChunk', new Blob([chunk]), `${filename}.part${index}`)
formData.append('uploadId', uploadId)
formData.append('totalChunks', String(totalChunks))
// Replace '/upload' with your actual back-end endpoint
const res = await fetch('/upload', {
method: 'POST',
body: formData,
signal: AbortSignal.timeout(60_000),
})
if (!res.ok) {
throw new Error(`Chunk rejected: HTTP ${res.status}`)
}
}
self.onmessage = async (event) => {
const file = event.data
try {
if (!(file instanceof File) || file.size === 0) throw new Error('Select a nonempty file')
const uploadId = crypto.randomUUID()
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
let index = 0
let uploaded = 0
for await (const chunk of readChunks(file)) {
await uploadChunk(chunk, file.name, index++, uploadId, totalChunks)
uploaded += chunk.length
self.postMessage({ type: 'progress', progress: uploaded / file.size * 100, message: 'Uploading.' })
}
const response = await fetch('/complete-upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, totalChunks }),
signal: AbortSignal.timeout(60_000),
})
if (!response.ok) throw new Error('Finalization failed')
self.postMessage({ type: 'complete', message: 'Upload finished successfully.' })
} catch {
self.postMessage({ type: 'error', message: 'Upload failed. Please try again.' })
}
}
Por que não ler o arquivo inteiro de uma vez no worker?
Embora ler o arquivo inteiro dentro do worker evite bloquear a thread principal, isso ainda consome bastante memória na própria thread do worker. Fazer streaming do arquivo bloco a bloco dentro do worker oferece várias vantagens:
- Menor consumo de memória: apenas pequenos blocos ficam na memória a cada momento.
- Backpressure: o próximo bloco da aplicação só é solicitado depois que o upload atual termina. O suporte a novas tentativas e à retomada ainda exige um protocolo no servidor e o estado confirmado salvo.
- Uploads paralelos de blocos: implementações avançadas poderiam enviar vários blocos simultaneamente (embora isso aumente a complexidade da ordenação e do tratamento no servidor).
Como criar um pequeno pool de workers
Usar um único worker ainda pode virar um gargalo se você precisar processar muitos arquivos
simultaneamente. Um WorkerPool limita as tarefas simultâneas e coloca os arquivos pendentes em fila.
Comece com um limite pequeno e meça; a quantidade de CPUs não é uma recomendação de largura de banda
para upload. Este pool cancela todo o trabalho quando ocorre uma falha no script do worker ou na
serialização de mensagens, em vez de reutilizar um worker quebrado.
class WorkerPool {
constructor(script, size = Math.min(navigator.hardwareConcurrency || 2, 4)) {
if (!Number.isInteger(size) || size < 1 || size > 4) throw new Error('Use 1 to 4 workers')
this.closed = false
this.workers = []
this.idleWorkers = []
this.taskQueue = []
this.taskCallbacks = new Map() // Map task ID to callbacks
console.log(`Initializing WorkerPool with size ${size}`)
for (let i = 0; i < size; i++) {
const worker = new Worker(script)
worker.id = `worker_${i}`
// Handle messages from the worker
worker.onmessage = (e) => this.handleWorkerMessage(worker, e.data)
// Handle errors occurring within the worker itself
worker.onerror = (e) => this.handleWorkerError(worker, e)
worker.onmessageerror = (e) => this.handleWorkerError(worker, e)
this.workers.push(worker)
this.idleWorkers.push(worker)
}
}
generateTaskId() {
return crypto.randomUUID()
}
processFile(file, callbacks) {
if (this.closed) {
callbacks.onError?.('The upload pool is closed.')
return
}
const taskId = this.generateTaskId()
const task = { id: taskId, file }
this.taskCallbacks.set(taskId, callbacks)
const idleWorker = this.idleWorkers.pop()
if (idleWorker) {
// An idle worker is available, run the task immediately
this.runTask(idleWorker, task)
} else {
// All workers are busy, add task to the queue
this.taskQueue.push(task)
console.log(
`Worker pool busy. Queued task ${taskId} for ${file.name}. Queue size: ${this.taskQueue.length}`,
)
}
}
runTask(worker, task) {
console.log(`Assigning task ${task.id} (${task.file.name}) to ${worker.id}`)
worker.currentTask = task // Associate task metadata with the worker
// Send the file object to the worker to start processing
// For large files, consider if Transferable Objects are applicable/needed
try {
worker.postMessage(task.file)
} catch (error) {
this.handleWorkerError(worker, error)
}
}
handleWorkerMessage(worker, data) {
const task = worker.currentTask
if (!task) {
console.warn(`Received message from worker ${worker.id} without an assigned task.`)
return
}
const callbacks = this.taskCallbacks.get(task.id)
if (!callbacks) {
console.warn(`Received message for unknown or completed task ${task.id}`)
return // Task might have been cancelled or already completed/failed
}
// Process messages based on their type
switch (data.type) {
case 'progress':
if (callbacks.onProgress) callbacks.onProgress(data.progress, data.message)
break
case 'complete':
this.finishTask(worker, task.id, () => callbacks.onComplete?.(data.message))
break
case 'error':
this.finishTask(worker, task.id, () => callbacks.onError?.(data.message))
break
default:
console.warn(`Received unknown message type from worker ${worker.id}:`, data.type)
}
}
handleWorkerError(worker, errorEvent) {
errorEvent.preventDefault?.()
this.terminate('An upload worker failed. Select your files to try again.')
}
finishTask(worker, taskId, notify) {
// Detach completed work before user callbacks can cancel or enqueue more work.
worker.currentTask = null
this.taskCallbacks.delete(taskId)
try {
notify()
} finally {
// A callback may terminate the pool; never return a dead worker to it.
if (!this.closed) {
const nextTask = this.taskQueue.shift()
if (nextTask) this.runTask(worker, nextTask)
else this.idleWorkers.push(worker)
}
}
}
terminate(message = 'Upload canceled.') {
if (this.closed) return
this.closed = true
console.log('Terminating worker pool...')
this.workers.forEach((worker) => {
console.log(`Terminating worker ${worker.id}`)
worker.terminate()
})
// Clear internal state
this.workers = []
this.idleWorkers = []
this.taskQueue = []
const callbacks = [...this.taskCallbacks.values()]
this.taskCallbacks.clear()
const errors = []
for (const callback of callbacks) {
try {
callback.onError?.(message)
} catch (error) {
errors.push(error)
}
}
if (errors.length > 0) throw new AggregateError(errors, 'Upload cancellation callbacks failed.')
}
}
Compatibilidade com navegadores
As APIs da web evoluem, então sempre verifique o suporte dos navegadores aos recursos de que você depende.
O método Blob.stream() é herdado
por File, então esses não são requisitos de compatibilidade separados. Verifique também Worker
e AbortSignal.timeout()
nos navegadores que você suporta. Este exemplo envia um File por clonagem estruturada; ele não
exige suporte a streams transferíveis.
Em navegadores sem suporte a File.stream(), talvez seja necessário recorrer a uma abordagem baseada
em FileReader (possivelmente dentro do worker para não bloquear a thread principal, mas ainda usando
mais memória) ou usar bibliotecas consolidadas como tus-js-client ou Uppy, que cuidam da
compatibilidade e oferecem recursos como a retomada de uploads.
Boas práticas de gerenciamento de memória
- Limite a quantidade de workers: comece com um limite pequeno e meça o comportamento de CPU, memória e rede. Criar workers demais pode causar troca de contexto excessiva e sobrecarga de memória.
- Encerre os workers: chame explicitamente
worker.terminate()oupool.terminate()quando os workers não forem mais necessários (por exemplo, depois que todos os uploads terminarem ou ao descarregar a página) para liberar recursos. Use blocostry...finallyna lógica da sua aplicação para garantir o encerramento mesmo que ocorram erros durante o processo de upload. - Libere referências: tanto na thread principal quanto nos workers, anule as referências a
objetos grandes (como objetos
File,Blob,ArrayBufferou leitores de stream) quando elas não forem mais necessárias (reader = null,file = null,chunk = null) para permitir a coleta de lixo. Garanta que os leitores de stream sejam liberados usandoreader.releaseLock(). Cancele também as streams não concluídas; liberar um lock não fecha a stream nem cancela seu produtor. - Tamanho do bloco: escolha um tamanho de bloco sensato (por exemplo, de 1 a 10 MiB). Blocos muito pequenos aumentam a sobrecarga de rede (mais requisições HTTP por arquivo), enquanto blocos muito grandes anulam parte dos benefícios de economia de memória do streaming.
- Monitore a memória: use as ferramentas de desenvolvedor do navegador (como o painel Memory do Chrome ou a ferramenta Memory do Firefox) durante o desenvolvimento e os testes para monitorar o uso de memória sob carga e identificar possíveis vazamentos.
O exemplo da thread principal encerra o pool em resposta a Cancel uploads ou a pagehide e depois
cria um novo pool quando o usuário seleciona arquivos novamente. Não encerre imediatamente depois de
enfileirar trabalho assíncrono, a menos que você pretenda cancelá-lo. A expiração no servidor precisa
limpar os bytes já recebidos.
Segurança e resiliência
- CORS: configure com cuidado, no servidor, a política de Cross-Origin Resource Sharing (CORS)
do seu endpoint de upload. Permita apenas os métodos HTTP necessários (POST e, possivelmente,
OPTIONS para requisições de preflight) e os cabeçalhos que o protocolo escolhido realmente usa,
e restrinja as origens (
Access-Control-Allow-Origin) ao domínio da sua aplicação. - Autenticação/autorização: proteja seu endpoint de upload. Em uploads em blocos, garanta que
cada requisição de bloco seja autenticada e autorizada. Os métodos incluem usar cookies de sessão
seguros HTTP-only, tokens bearer (JWTs) enviados no cabeçalho
Authorizationou gerar URLs pré-assinadas para cada bloco ou para toda a sessão de upload (comum com armazenamento em nuvem). - Novas tentativas: problemas de rede são comuns. Implemente um mecanismo de novas tentativas
na sua função
uploadChunkpara uploads de blocos que falharem. Use backoff exponencial (esperando cada vez mais entre as tentativas: por exemplo, 1 s, 2 s, 4 s) para não sobrecarregar o servidor ou a rede. Pare de tentar depois de um número razoável de tentativas (por exemplo, 3 a 5). - Cancelamento: ofereça aos usuários uma forma de cancelar uploads em andamento. Use a API
AbortController. Crie uma instância deAbortControllerantes de iniciar o upload, passe osignaldela para cada requisiçãofetche chamecontroller.abort()quando o usuário cancelar. Garanta que seu tratamento de erros capture oAbortError.
Um AbortController deve ficar no mesmo worker que a requisição. Um controlador da thread principal não
é compartilhado automaticamente com esse worker. Em vez disso, este exemplo interrompe todos os
workers ativos em Cancel uploads; o cancelamento por arquivo exigiria uma mensagem
explícita para o worker e um contrato de remoção da fila.
Depuração de Web Workers
Depurar workers pode ser um pouco diferente de depurar a thread principal:
- DevTools do navegador: no painel Sources do Chrome, selecione o
worker no painel Threads para
alterar o contexto de depuração.
No Debugger do Firefox, abra o arquivo-fonte de um worker ativo.
Os dois navegadores oferecem breakpoints, inspeção de variáveis e saída de
console.logpara workers; consulte depuração de threads de workers. - Tratamento de erros: uma comunicação robusta de erros via
postMessage(como mostrado nos exemplos) é essencial para entender problemas que ocorrem dentro do worker, já que umtry...catchdireto na thread principal não captura erros do worker. Garanta que os erros do worker sejam capturados explicitamente e enviados de volta.
Armadilhas comuns
- Workers em excesso: criar um novo worker para cada arquivo em vez de usar um pool pode sobrecarregar os recursos do sistema (CPU e memória).
- Locks de stream: esquecer de chamar
reader.releaseLock()em umReadableStreamDefaultReaderdepois de terminar a leitura ou ao encontrar um erro. Sempre use um blocofinallyparareleaseLock()e cancele uma stream não concluída antes de liberá-la. - Payloads de mensagem grandes: evite enviar objetos de dados muito grandes entre a thread
principal e os workers com
postMessage, pois isso envolve sobrecarga de serialização e desserialização (ou clonagem estruturada). Para dados binários grandes, considere usar objetosTransferable(comoArrayBuffer) para transferências mais eficientes, sem cópia, onde houver suporte e fizer sentido. - Ordem dos blocos: presumir que o servidor receberá os blocos exatamente na ordem em que foram enviados. A latência da rede e as requisições simultâneas podem alterar essa ordem. Sempre inclua um índice ou offset de bytes em cada bloco para que o servidor possa remontar o arquivo corretamente.
- Erros não tratados: a falta de blocos
try...catchadequados dentro do worker, principalmente em torno de operações assíncronas como a leitura de streams (reader.read()) e as requisições de rede (fetch), pode causar falhas silenciosas ou rejeições de promise não tratadas dentro do worker.
Principais conclusões
- Web Workers podem tirar da thread principal o processamento de arquivos que exige muita CPU, mantendo a interface responsiva durante os uploads.
- Streams de JavaScript permitem lidar com arquivos grandes de forma eficiente ao processar os dados em blocos, o que reduz o buffering na aplicação. A retomada de uploads é uma questão separada, de protocolo.
- Um pool de workers limita o processamento simultâneo, ajudando a controlar o uso de recursos ao lidar com vários uploads simultâneos.
- Tratamento robusto de erros (incluindo novas tentativas de rede e tratamento de erros de stream),
limpeza adequada de recursos (
terminate,releaseLock), cuidados de segurança (CORS, autenticação) e atenção à compatibilidade com navegadores são essenciais para implementações prontas para produção.
Para uma solução pronta para produção que já cuide da divisão em blocos, da retomada, das novas tentativas e dos uploads paralelos, considere usar bibliotecas como o Uppy com seus diversos plugins de upload, ou conheça serviços projetados para um tratamento robusto de arquivos. O serviço de uploads de arquivos da Transloadit integra esses conceitos para uploads confiáveis de arquivos grandes. Bons uploads!
