Otimizar uploads de arquivos on-line em partes e em paralelo
Para fazer upload de partes em paralelo, atribua uma posição estável a cada parte e publique o arquivo somente depois que o receptor verificar o resultado completo. Aqui você vai criar os dois lados: um navegador envia três partes por vez, e um servidor Node.js local disponibiliza o arquivo montado para download depois de verificar seu resumo SHA-256.
Escolha um upload pequeno e reproduzível
Este exemplo é para desenvolvedores que estão aprendendo como funciona o upload de partes em paralelo. Use o Node.js 24.15.0 ou uma versão posterior ainda mantida e um navegador Chromium atual; o exemplo foi testado no Linux com Node.js 24.15.0 e Chromium 145. O Node.js 24 é uma versão LTS. Não há pacotes para instalar.
A página aceita um JPEG, PNG ou PDF não vazio de até 8 MiB. Esse limite pequeno é intencional: o
receptor armazena os arquivos na memória, e o navegador calcula o hash de arquivos inteiros com
crypto.subtle.digest(),
que não aceita entrada em fluxo. Esta é uma demonstração de protocolo local, não um serviço de
armazenamento de arquivos grandes. Reiniciar o servidor causa a perda de todos os arquivos.
Recarregar a página causa a perda do estado do upload no cliente.
Atribua uma posição a cada parte
Use partes de 256 KiB, numeradas a partir de zero. Um arquivo de 524.295 bytes tem três partes:
duas de 262.144 bytes e uma de sete bytes.
Blob.slice(start, end)
exclui a posição final, então os recortes adjacentes não se sobrepõem nem deixam lacunas.
O protocolo tem cinco operações:
POST /uploadsreserva um tamanho de arquivo e um resumo SHA-256 e retorna um ID gerado pelo servidor.PUT /uploads/:id/:indexgrava a parte bruta na posição correspondente ao seu número. Uma nova tentativa idêntica tem sucesso; um corpo diferente em uma posição já aceita falha com HTTP 409.POST /uploads/:id/completeverifica se todas as posições estão presentes e se o resumo do arquivo inteiro corresponde ao esperado. Repetir essa operação retorna o mesmo resumo.GET /uploads/:id/fileretorna bytes somente após a conclusão. O navegador também verifica esses bytes.DELETE /uploads/:idremove o upload, incluindo um arquivo concluído.
A ordem de chegada não determina a ordem do arquivo. Uma confirmação perdida pode causar uma requisição duplicada, por isso as gravações das partes e a conclusão devem ser idempotentes. A criação não é repetida automaticamente: se a resposta de criação se perder, o ID fica desconhecido e acaba expirando no servidor.
Crie a página
Crie um diretório vazio e salve os três arquivos a seguir lado a lado. Salve este primeiro arquivo
como index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Parallel chunk upload</title>
</head>
<body>
<main>
<h1>Parallel chunk upload</h1>
<label for="file">JPEG, PNG, or PDF, up to 8 MiB</label>
<input id="file" type="file" accept="image/jpeg,image/png,application/pdf" />
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button" disabled>Cancel</button>
<p id="status" role="status">Choose a file.</p>
<a id="download" hidden download="upload.bin">Download verified file</a>
</main>
<script type="module" src="/client.js"></script>
</body>
</html>
Receba e publique as partes
Salve este arquivo como server.mts. A extensão .mts
o torna um módulo ES mesmo dentro de um projeto CommonJS.
O servidor usa
stripTypeScriptTypes()
do Node para servir o próximo arquivo como JavaScript. No Node.js 24.15.0, essa API emite um aviso
de recurso experimental.
Podem existir quatro uploads ao mesmo tempo, incluindo uploads concluídos. Cada um expira 60 segundos após a criação, mesmo que ainda estejam chegando requisições. Seis requisições podem estar ativas, cada uma com um prazo de 10 segundos. Os corpos recebidos são lidos antes da consulta aos dados da sessão, então um corpo pendente não pode manter uma sessão excluída ativa nem gravar nela depois.
import { createHash, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { stripTypeScriptTypes } from 'node:module'
const CHUNK_SIZE = 256 * 1024
const MAX_FILE_SIZE = 8 * 1024 * 1024
const MAX_UPLOADS = 4
const MAX_REQUESTS = 6
const TTL_MS = 60_000
type Upload = {
bytes: Buffer
digest: string
seen: Set<number>
complete: boolean
expires: number
}
const uploads = new Map<string, Upload>()
class HttpError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
}
}
function expire(): void {
for (const [id, upload] of uploads) {
if (upload.expires <= Date.now()) uploads.delete(id)
}
}
async function readBody(req: IncomingMessage, limit: number): Promise<Buffer> {
const bytes = Buffer.alloc(limit)
let length = 0
// Leave the socket open long enough to send a useful error response.
for await (const part of req.iterator({ destroyOnReturn: false })) {
if (!Buffer.isBuffer(part)) throw new HttpError(400, 'Expected bytes')
if (length + part.length > limit) throw new HttpError(413, 'Body too large')
part.copy(bytes, length)
length += part.length
}
return bytes.subarray(0, length)
}
async function main(): Promise<void> {
const html = await readFile(new URL('./index.html', import.meta.url))
const client = stripTypeScriptTypes(
await readFile(new URL('./client.ts', import.meta.url), 'utf8'),
)
const port = Number(process.argv[2] ?? 0)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid port')
let origin = ''
let active = 0
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.headers.host !== new URL(origin).host ||
(req.headers.origin !== undefined && req.headers.origin !== origin)) {
throw new HttpError(403, 'Use the printed local URL')
}
const method = req.method
if (method !== 'GET' && req.headers['x-upload-demo'] !== '1') {
throw new HttpError(403, 'Missing demo header')
}
const path = new URL(req.url ?? '/', origin).pathname
const body = await readBody(req, method === 'PUT' ? CHUNK_SIZE : 0)
expire()
res.setHeader('Cache-Control', 'no-store')
res.setHeader('X-Content-Type-Options', 'nosniff')
if (method === 'GET' && (path === '/' || path === '/client.js')) {
res.setHeader('Content-Type', path === '/' ? 'text/html; charset=utf-8' : 'text/javascript')
res.end(path === '/' ? html : client)
return
}
if (method === 'POST' && path === '/uploads') {
const length = req.headers['upload-length']
const digest = req.headers['upload-sha256']
const size = Number(length)
if (typeof length !== 'string' || !/^[1-9]\d*$/.test(length) ||
!Number.isSafeInteger(size) || size > MAX_FILE_SIZE) {
throw new HttpError(400, 'File must be between 1 byte and 8 MiB')
}
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest)) {
throw new HttpError(400, 'Expected a SHA-256 digest')
}
if (uploads.size >= MAX_UPLOADS) throw new HttpError(503, 'Upload capacity reached')
const id = randomUUID()
uploads.set(id, {
bytes: Buffer.alloc(size), digest, seen: new Set(), complete: false,
expires: Date.now() + TTL_MS,
})
res.writeHead(201).end(id)
return
}
const match = /^\/uploads\/([a-f0-9-]{36})(?:\/(\d+|complete|file))?$/.exec(path)
if (!match) throw new HttpError(404, 'Unknown route')
const [, id, operation] = match
if (method === 'DELETE' && operation === undefined) {
uploads.delete(id)
res.writeHead(204).end()
return
}
const upload = uploads.get(id)
if (!upload) throw new HttpError(404, 'Upload missing or expired')
const count = Math.ceil(upload.bytes.length / CHUNK_SIZE)
if (method === 'PUT' && operation !== undefined && /^\d+$/.test(operation)) {
const index = Number(operation)
if (!Number.isSafeInteger(index) || index >= count) {
throw new HttpError(400, 'Invalid chunk index')
}
const start = index * CHUNK_SIZE
const target = upload.bytes.subarray(start, Math.min(start + CHUNK_SIZE, upload.bytes.length))
if (body.length !== target.length) throw new HttpError(400, 'Wrong chunk length')
if (upload.seen.has(index)) {
if (!body.equals(target)) throw new HttpError(409, 'Conflicting chunk')
} else {
body.copy(target)
upload.seen.add(index)
}
res.writeHead(204).end()
return
}
if (method === 'POST' && operation === 'complete') {
if (upload.seen.size !== count) throw new HttpError(409, 'Missing chunks')
if (createHash('sha256').update(upload.bytes).digest('hex') !== upload.digest) {
throw new HttpError(422, 'Digest mismatch')
}
upload.complete = true
res.end(upload.digest)
return
}
if (method === 'GET' && operation === 'file') {
if (!upload.complete) throw new HttpError(409, 'Upload is not complete')
res.setHeader('Content-Type', 'application/octet-stream')
res.setHeader('Content-Disposition', 'attachment; filename="upload.bin"')
res.end(upload.bytes)
return
}
throw new HttpError(405, 'Unsupported operation')
}
const server = createServer({ requestTimeout: 10_000, headersTimeout: 10_000 }, (req, res) => {
if (active >= MAX_REQUESTS) {
res.writeHead(503, { Connection: 'close' }).end('Too many requests')
return
}
active++
let handled = false
let closed = false
const deadline = setTimeout(() => { req.destroy(); res.destroy() }, 10_000)
function release(): void {
if (handled && closed) { clearTimeout(deadline); active-- }
}
res.once('close', () => { closed = true; release() })
handle(req, res).catch((error: unknown) => {
const status = error instanceof HttpError ? error.status : 500
const message = error instanceof HttpError ? error.message : 'Request failed'
if (!res.destroyed) res.writeHead(status, { Connection: 'close' }).end(message)
}).finally(() => { handled = true; release() })
})
server.maxConnections = 16
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(port, '127.0.0.1', () => resolve())
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Missing server address')
origin = `http://127.0.0.1:${address.port}`
setInterval(expire, 1000).unref()
console.log(`Open ${origin}`)
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : 'Could not start the server')
process.exitCode = 1
})
Os quatro buffers de sessão totalizam no máximo 32 MiB. Separadamente, uma requisição admitida pode reter um buffer de entrada de 256 KiB ou referenciar um arquivo de saída de 8 MiB até que sua resposta seja encerrada. A exclusão e a expiração não liberam antecipadamente a vaga dessa requisição. Esses são limites de buffer da aplicação, não limites para o uso total de memória do Node nem para o momento da coleta de lixo. Os tempos limite de cabeçalho e o limite de conexões também restringem as conexões em espera deste servidor local; consulte a documentação HTTP do Node.js.
Envie no máximo três partes por vez
Salve este arquivo como client.ts. Cada lote espera que todas as suas
requisições terminem antes que outro lote comece. Uma parte lenta, portanto, atrasa seu lote, mas o
limite é fácil de inspecionar e as novas tentativas não podem multiplicar o número de requisições
de partes ativas.
const CHUNK_SIZE = 256 * 1024
const MAX_FILE_SIZE = 8 * 1024 * 1024
const CONCURRENCY = 3
const input = document.getElementById('file')
const uploadButton = document.getElementById('upload')
const cancelButton = document.getElementById('cancel')
const status = document.getElementById('status')
const download = document.getElementById('download')
if (!(input instanceof HTMLInputElement) || !(uploadButton instanceof HTMLButtonElement) ||
!(cancelButton instanceof HTMLButtonElement) || !(status instanceof HTMLParagraphElement) ||
!(download instanceof HTMLAnchorElement)) throw new Error('Missing upload controls')
class HttpError extends Error {
status: number
constructor(status: number) { super(`HTTP ${status}`); this.status = status }
}
async function validateFile(file: File): Promise<void> {
if (file.size === 0 || file.size > MAX_FILE_SIZE) throw new Error('Choose a file between 1 byte and 8 MiB.')
const signatures: Record<string, number[]> = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'application/pdf': [0x25, 0x50, 0x44, 0x46],
}
const expected = signatures[file.type]
if (!expected) throw new Error('Choose a JPEG, PNG, or PDF.')
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer())
if (!expected.every((byte, index) => header[index] === byte)) throw new Error('Invalid file signature.')
}
async function sha256(blob: Blob): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer())
return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join('')
}
function waitForRetry(ms: number, signal: AbortSignal): Promise<void> {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const onAbort = () => { clearTimeout(timer); reject(signal.reason) }
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, ms)
signal.addEventListener('abort', onAbort, { once: true })
})
}
async function request(
path: string, options: RequestInit, signal: AbortSignal, retry = true,
): Promise<Blob> {
for (let attempt = 0; ; attempt++) {
signal.throwIfAborted()
try {
const response = await fetch(path, {
...options,
signal: AbortSignal.any([signal, AbortSignal.timeout(5000)]),
headers: { ...options.headers, 'X-Upload-Demo': '1' },
})
if (!response.ok) throw new HttpError(response.status)
return await response.blob()
} catch (error) {
signal.throwIfAborted()
if (!retry || attempt === 2 ||
(error instanceof HttpError && ![408, 429, 500, 502, 503, 504].includes(error.status))) {
throw error
}
await waitForRetry(250 * 2 ** attempt, signal)
}
}
}
let running: AbortController | null = null
cancelButton.addEventListener('click', () => running?.abort())
input.addEventListener('change', () => {
if (running) return
download.hidden = true
status.textContent = 'Ready to upload.'
})
uploadButton.addEventListener('click', async () => {
if (running) return
const file = input.files?.[0]
if (!file) { status.textContent = 'Choose a file.'; return }
const controller = new AbortController()
const signal = controller.signal
running = controller
input.disabled = uploadButton.disabled = true
cancelButton.disabled = false
download.hidden = true
let id: string | undefined
let verified = false
try {
status.textContent = 'Checking file…'
await validateFile(file)
const digest = await sha256(file)
signal.throwIfAborted()
// Finish creation so cancellation can learn the ID and delete it.
id = await (await request('/uploads', {
method: 'POST', headers: { 'Upload-Length': String(file.size), 'Upload-SHA256': digest },
}, new AbortController().signal, false)).text()
signal.throwIfAborted()
const count = Math.ceil(file.size / CHUNK_SIZE)
let acknowledged = 0
for (let first = 0; first < count; first += CONCURRENCY) {
signal.throwIfAborted()
const batch = []
for (let index = first; index < Math.min(first + CONCURRENCY, count); index++) {
const chunk = file.slice(index * CHUNK_SIZE, (index + 1) * CHUNK_SIZE)
batch.push(request(`/uploads/${id}/${index}`, { method: 'PUT', body: chunk }, signal).then(() => {
signal.throwIfAborted()
acknowledged += chunk.size
status.textContent = `${Math.round(100 * acknowledged / file.size)}% of bytes acknowledged.`
}))
}
const results = await Promise.allSettled(batch)
const failure = results.find((result) => result.status === 'rejected')
if (failure) throw failure.reason
}
status.textContent = 'All chunks acknowledged. Verifying…'
const confirmation = await request(`/uploads/${id}/complete`, { method: 'POST' }, signal)
if (await confirmation.text() !== digest) throw new Error('Unexpected confirmation.')
const result = await request(`/uploads/${id}/file`, {}, signal)
if (result.size !== file.size || await sha256(result) !== digest) throw new Error('Downloaded bytes differ.')
signal.throwIfAborted()
verified = true
download.href = `/uploads/${id}/file`
download.hidden = false
status.textContent = 'Upload verified. Download is available until the upload expires.'
} catch (error) {
status.textContent = signal.aborted ? 'Upload canceled.' :
`Upload failed: ${error instanceof Error ? error.message : 'Please try again.'}`
} finally {
if (id && !verified) {
try {
await request(`/uploads/${id}`, { method: 'DELETE' }, new AbortController().signal)
} catch {
status.textContent += ' Cleanup could not be confirmed; the server will expire the upload.'
}
}
running = null
input.disabled = uploadButton.disabled = false
cancelButton.disabled = true
}
})
A porcentagem conta os bytes confirmados, incluindo uma última parte menor. Ela não mede os bytes atualmente em trânsito. Nem mesmo 100% significa sucesso: a conclusão e a verificação posterior do download precisam ter sucesso antes que o link apareça. Mais requisições paralelas podem melhorar a taxa de transferência quando uma requisição deixa capacidade ociosa, mas também adicionam sobrecarga. Faça medições com seu próprio receptor e sua rede; esta demonstração não faz nenhuma afirmação sobre velocidade.
Execute e interrompa um upload
No diretório que contém os três arquivos salvos, execute:
node server.mts
Abra a URL http://127.0.0.1:PORT exibida. Não abra index.html
diretamente. Selecione um arquivo e clique em
Upload. Após
All chunks acknowledged. Verifying…, o link
Download verified file aparece. A página já buscou e
verificou o arquivo do servidor; clicar no link inicia um download separado, cujo local de
salvamento é controlado pelo navegador. O servidor sempre sugere upload.bin
como nome do arquivo baixado.
Enquanto houver trabalho pendente, o campo de seleção de arquivo e o botão de upload ficam desativados. Clique em Cancel para abortar as requisições e as esperas entre tentativas. A criação e o cálculo local do hash terminam antes que o cancelamento seja detectado; a limpeza então tenta excluir o ID conhecido. Os controles permanecem desativados até que a limpeza termine. Uma falha na limpeza ou uma resposta de criação perdida pode deixar dados até a expiração. Cancelar uma requisição não desfaz uma conclusão já processada pelo servidor, por isso a limpeza também exclui uploads concluídos.
AbortSignal.any() e AbortSignal.timeout()
combinam o cancelamento pelo usuário com um tempo limite de cinco segundos por tentativa de
requisição. Falhas de rede e os status HTTP temporários listados permitem no máximo três tentativas,
com intervalos de 250 ms e 500 ms entre elas.
Outros erros HTTP falham imediatamente. Esses tempos limite do navegador usam o tempo ativo e
podem pausar enquanto a página estiver suspensa; o prazo e a expiração do servidor são independentes.
Uma resposta 409 significa que faltam partes ou que uma duplicata está em conflito. Uma resposta
422 significa que o resumo do arquivo montado está incorreto. Uma resposta 503 pode significar que
todas as quatro vagas de upload estão ocupadas, incluindo arquivos concluídos; espere a expiração
e comece novamente. IDs ausentes ou expirados retornam 404. Pare o servidor com Ctrl+C ao terminar.
Para usar uma porta livre específica, passe seu número após server.mts;
se a porta estiver ocupada, o processo será encerrado com um erro em vez de exibir uma URL pronta
para uso.
Mantenha explícito o limite de uso local
O receptor escuta somente em 127.0.0.1, verifica o host e a origem do navegador,
exige um cabeçalho personalizado para alterações e nunca usa um nome de arquivo fornecido como
caminho. Ele serve bytes opacos como anexos.
A verificação de assinatura no cliente detecta uma seleção equivocada de arquivo; nem um prefixo
correspondente nem um resumo correspondente comprovam que um arquivo é seguro. O receptor aplica
de forma independente as restrições de tamanho, posições, capacidade e resumo, mas não valida a
estrutura da imagem ou do PDF nem faz varredura de malware.
Não exponha este servidor como um serviço público de upload. Um receptor em produção precisa de autenticação, autorização e cotas por usuário, HTTPS, armazenamento durável e validação de conteúdo adequada a quem o consome. Os IDs aqui isolam os uploads locais; eles não são um sistema de permissões de conta.
Escolha o próximo passo
Experimente um arquivo um pouco maior que duas partes e observe as requisições no painel de rede do navegador. A última parte deve ser menor, e a URL do arquivo deve ficar utilizável somente após a conclusão. Esse limite é o que você deve manter ao substituir a memória por armazenamento durável.
Para recuperar o upload após recarregar a página, use um protocolo mantido e persista estado
suficiente para reconciliá-lo com o receptor.
O tus define a descoberta da posição com HEAD
e a retomada com PATCH; uploads parciais em paralelo usam sua extensão
opcional de concatenação. Esta demonstração de partes numeradas usa um protocolo separado, não é um
cliente tus. O plugin Tus do Uppy
é um próximo passo prático quando você quer um cliente de navegador mantido para um servidor tus
compatível.
