Ir para o conteúdo
HexaTransfer
Voltar ao blog
Analises tecnicas

Implementar Upload de Ficheiros em Chunks em JavaScript

Implemente uploads retomáveis em chunks com JavaScript. Processe ficheiros grandes, acompanhe o progresso e recupere de interrupções de rede.

O upload de ficheiros em chunks em JavaScript divide um ficheiro grande em partes de tamanho fixo (tipicamente 5 a 10 MB), faz upload de cada uma como um pedido HTTP separado, e reassembla-as no servidor. O padrão resolve três problemas reais: os browsers e proxies cancelam pedidos acima de 2 GB, as redes móveis perdem ligações a meio do upload, e os utilizadores querem feedback de progresso. Uma implementação funcional usa File.slice() para recortar chunks, fetch com um AbortSignal por chunk, assemblagem do lado do servidor via S3 multipart ou um assembler personalizado, e um índice local no IndexedDB para que a retoma sobreviva a recarregamentos de separador.

Por que os Chunks Superam os Uploads de Uma Só Vez

Um ficheiro de 4 GB enviado num único pedido falha por razões previsíveis: o client_max_body_size padrão do Nginx é 1 MB, a Cloudflare limita uploads do tier gratuito a 100 MB por pedido, o AWS API Gateway para forçosamente nos 10 MB, e o Safari móvel encerra separadores que mantêm um ArrayBuffer de 4 GB em memória. Os uploads em chunks contornam todos esses limites. Obtém também barras de progresso que realmente se movem, retries que não reiniciam do zero, e a capacidade de pausar e retomar. O trade-off é mais estado do lado do servidor e mais round trips — aproximadamente um pedido HTTP por 5 MB, o que num ficheiro de 10 GB significa 2.000 pedidos.

Escolha do Tamanho do Chunk

O tamanho do chunk é um trade-off entre débito e resiliência. Muito pequeno (abaixo de 1 MB) e gasta mais tempo em handshakes TLS do que em dados. Muito grande (acima de 100 MB) e uma ligação perdida desperdiça minutos de upload. O ponto ideal para a maioria das redes é 5 a 10 MB, o que corresponde ao mínimo de 5 MB do S3 multipart e alinha bem com os tamanhos típicos de janela TCP após slow-start.

Meça primeiro a rede do utilizador:

const downlink = navigator.connection?.downlink ?? 10;
const chunkSize = downlink > 20 ? 10 * 1024 * 1024 : 5 * 1024 * 1024;

Numa ligação de 100 Mbit, os chunks de 10 MB terminam em cerca de um segundo cada. Em 4G, os chunks de 5 MB dão melhor recuperação quando encontra um túnel.

Fatiamento e Hash do Ficheiro

File.slice() devolve um Blob que referencia os mesmos bytes subjacentes do disco sem copiar, pelo que fatiar um ficheiro de 20 GB não custa nada:

function* sliceFile(file, chunkSize) {
  for (let offset = 0; offset < file.size; offset += chunkSize) {
    yield {
      index: Math.floor(offset / chunkSize),
      blob: file.slice(offset, offset + chunkSize),
      start: offset,
      end: Math.min(offset + chunkSize, file.size)
    };
  }
}

Calcule um hash SHA-256 de cada chunk antes de fazer upload para que o servidor possa verificar a integridade:

const buffer = await chunk.blob.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buffer);
const hash = Array.from(new Uint8Array(digest))
  .map(b => b.toString(16).padStart(2, '0')).join('');

Para 10 GB de dados, o hashing adiciona talvez 20 segundos num portátil moderno — vale a pena para apanhar corrupção silenciosa em uplinks celulares instáveis.

Upload com Concorrência Controlada

Os uploads sequenciais desperdiçam largura de banda; o paralelismo ilimitado faz o browser falhar. Um limite de concorrência de 3 a 4 chunks em voo equilibra ambos:

async function uploadAll(file, sessionId) {
  const queue = [...sliceFile(file, 5 * 1024 * 1024)];
  const workers = Array.from({ length: 4 }, async () => {
    while (queue.length) {
      const chunk = queue.shift();
      await uploadChunk(chunk, sessionId);
      emitProgress(chunk.index);
    }
  });
  await Promise.all(workers);
}

Cada chamada uploadChunk é um PUT /upload/:sessionId/:index com o blob como corpo e o hash num cabeçalho. Use AbortController por chunk para poder cancelar pedidos individuais sem matar todo o lote.

Retry Sem Sobrecarregar o Servidor

Os erros de rede precisam de backoff exponencial, não de ciclos de retry apertados. Uma política razoável: 3 tentativas, atraso base de 500 ms, jitter até 50%:

async function uploadChunk(chunk, sessionId, attempt = 0) {
  try {
    const res = await fetch(`/upload/${sessionId}/${chunk.index}`, {
      method: 'PUT', body: chunk.blob, headers: { 'X-Hash': chunk.hash }
    });
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
  } catch (e) {
    if (attempt >= 3) throw e;
    const delay = 500 * 2 ** attempt + Math.random() * 250;
    await new Promise(r => setTimeout(r, delay));
    return uploadChunk(chunk, sessionId, attempt + 1);
  }
}

Trate respostas 5xx como retryable, 4xx como fatais (exceto 408 e 429). Em 429, respeite o cabeçalho Retry-After em vez do seu backoff local.

Retoma Após Recarregamento do Separador

Persista o estado do upload no IndexedDB após cada chunk bem-sucedido:

await db.put('uploads', {
  sessionId, fileName: file.name, fileSize: file.size,
  completedChunks: [...completedSet], updatedAt: Date.now()
}, sessionId);

Quando o utilizador reabre a página com o mesmo seletor de ficheiros, compare o size, lastModified e nome do ficheiro com as sessões armazenadas. Se houver correspondência, pergunte ao servidor que chunks já recebeu (um simples GET /upload/:sessionId/status a devolver um bitmap funciona), depois faça upload apenas dos que faltam. O protocolo tus formaliza exatamente este padrão com o cabeçalho Upload-Offset, e a biblioteca tus-js-client fornece uma implementação sólida se não quiser implementar a sua própria. O tus.io é amplamente utilizado precisamente porque cobre estes casos extremos de forma fiável.

Assemblagem de Chunks no Servidor

Duas opções sérias: S3 multipart upload, onde cada chunk se torna um PartNumber e um CompleteMultipartUpload final os une, ou um assembler personalizado que escreve cada chunk para um ficheiro temporário e concatena no final. O S3 multipart é mais barato à escala porque nunca paga egresso durante a assemblagem e o R2 dá leituras zero-egresso. A abordagem personalizada é mais simples de depurar e permite encriptar em streaming durante a assemblagem.

Para estilo S3:

const upload = await s3.createMultipartUpload({ Bucket, Key });
// por chunk: s3.uploadPart({ UploadId, PartNumber, Body })
await s3.completeMultipartUpload({ UploadId, MultipartUpload: { Parts } });

Cuidado com o limite de 10.000 partes — para ficheiros acima de 50 GB precisa de chunks de 5 MB+ para se manter abaixo dele.

Acompanhamento de Progresso em que os Utilizadores Confiam

As barras de progresso que saltam parecem defeituosas. Calcule o progresso como bytes carregados sobre o total de bytes, não chunks completos, e suavize-o com uma média móvel de 2 segundos para esconder variações. Use fetch com um ReadableStream e um Transform para contar bytes, uma vez que XMLHttpRequest.upload.onprogress nem sempre dispara de forma fiável sobre HTTP/3. Mostre um ETA dividindo os bytes restantes pelo débito recente, mas limite a pelo menos 5 segundos para evitar a infame experiência de "2 segundos restantes... durante 10 minutos".

Erros Comuns a Evitar

Três erros destroem os uploads em chunks em produção: esquecer de definir Content-Length por chunk (quebra alguns proxies de edge), reutilizar o mesmo ID de sessão para ficheiros diferentes (corrompe a assemblagem), e deixar o utilizador alterar o ficheiro a meio do upload sem versionar a sessão. Faça sempre hash dos primeiros 1 MB do ficheiro mais o seu tamanho e lastModified para identificar as sessões. E nunca confie no lastModified sozinho — o macOS Finder atualiza-o em alterações de metadados.

O HexaTransfer usa um pipeline em chunks mais retomável como este por baixo para os seus uploads de 10 GB, com AES-256-GCM do lado do cliente adicionado a cada chunk antes do PUT. Experimente em https://hexatransfer.com — gratuito, sem conta, máximo de 10 GB.

Envie arquivos grandes com segurança e criptografia de ponta a ponta

Transfira arquivos de até 10 GB gratuitamente com criptografia de ponta a ponta. Sem necessidade de conta. Seus arquivos são criptografados no navegador antes do envio — ninguém mais pode lê-los.

Enviar um arquivo