Otimização de uploads de arquivos em aplicações web
O desempenho de uploads inclui o tempo gasto preparando um arquivo, transferindo-o, repetindo requisições perdidas e aguardando a validação do servidor. Este DevTip cria um worker com limites para preparação de imagens e uma integração de uploads retomáveis no navegador, com o Uppy como interface alternativa. Meça essas etapas nos seus próprios dispositivos e na sua rede antes de escolher configurações de compressão ou aumentar a concorrência.
Estes exemplos se integram a uma aplicação existente com sessões, conforme o contrato do gateway
abaixo. Execute a configuração e as compilações na raiz do projeto Yarn 4 dessa aplicação, com um
package.json e um diretório public/.
Os comandos atualizam dependências e substituem os recursos compilados indicados; eles não criam
nem iniciam um servidor de aplicação. Node 26.11.1 e Yarn 4.18.1 são as versões testadas para a
compilação, não versões mínimas intrínsecas para uploads no navegador. Instale o Yarn separadamente
seguindo seu guia de instalação. O navegador precisa de workers de
módulo, createImageBitmap, OffscreenCanvas e
AbortSignal.any()/timeout().
Desafios comuns em uploads de arquivos
Arquivos grandes podem esgotar a memória, redes lentas podem causar tempos limite e interrupções podem deixar o navegador sem saber se uma requisição foi bem-sucedida. Uma interface útil distingue os bytes transferidos da aceitação pelo servidor. A segurança depende da verificação de que o upload pertence ao usuário autenticado e da validação no servidor, mesmo quando o navegador rejeita primeiro arquivos claramente inadequados.
Boas práticas para otimizar o desempenho de uploads de arquivos
1. Use uploads em blocos
Use um protocolo retomável com deslocamentos explícitos em vez de enviar partes anônimas de arquivos. A integração com tus abaixo usa blocos de 1 MiB e limita as novas tentativas. Isso limita o tamanho das retransmissões; não promete uma taxa de transferência maior em todas as conexões. Falhas HTTP não recuperadas fazem a operação ser rejeitada, inclusive falhas após o último byte chegar ao servidor.
2. Implemente compressão no lado do cliente
Redimensionar imagens pode reduzir a quantidade de bytes, com custos de CPU, qualidade e metadados. O worker opcional abaixo aceita arquivos JPEG ou PNG de até 10 MiB, limita as imagens decodificadas a 16 milhões de pixels, ajusta ambas as dimensões para caber em 1024 × 1024 e produz um JPEG. A transparência se torna branca e os metadados não são copiados da entrada. O codificador JPEG pode adicionar seus próprios metadados, como a resolução. A conversão de cores do navegador pode alterar as cores, inclusive em JPEGs CMYK; inspecione o resultado e mantenha o original quando essas alterações forem inaceitáveis. A decodificação pode alocar memória antes que as dimensões sejam conhecidas; este recurso de conveniência tem limites e atende fotos comuns de usuários, mas não é uma defesa contra decodificadores de imagem maliciosos.
A decodificação no navegador também não comprova que uma entrada danificada está íntegra. A validação de conteúdo no lado do servidor continua se aplicando ao JPEG preparado. A preparação tem um prazo de 30 segundos; um dispositivo lento pode excedê-lo mesmo com uma imagem elegível.
Crie image-worker.js:
self.onmessage = async ({ data: file }) => {
let bitmap
try {
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!['image/jpeg', 'image/png'].includes(file.type)
) {
throw new Error('Unsupported image')
}
bitmap = await createImageBitmap(file)
if (bitmap.width * bitmap.height > 16_000_000) throw new Error('Image too large')
const scale = Math.min(1, 1024 / bitmap.width, 1024 / bitmap.height)
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = new OffscreenCanvas(width, height)
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas unavailable')
context.fillStyle = 'white'
context.fillRect(0, 0, width, height)
context.drawImage(bitmap, 0, 0, width, height)
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.8 })
if (blob.type !== 'image/jpeg' || blob.size === 0) throw new Error('Encoding failed')
self.postMessage({ blob })
} catch {
self.postMessage({ error: 'Image preparation failed.' })
} finally {
bitmap?.close()
}
}
3. Use Web Workers para processamento em segundo plano
Workers são úteis aqui para decodificação e codificação. Fetch e a divisão de Blobs não precisam de
um worker por requisição. Este wrapper prepare-image.js encerra seu único worker em
caso de sucesso, erro de execução, erro de decodificação de mensagem, cancelamento, tempo limite ou
falha em postMessage:
export function prepareImage(file, signal) {
signal.throwIfAborted()
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./image-worker.js', import.meta.url), { type: 'module' })
let settled = false
let timer
const finish = (error, blob) => {
if (settled) return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', abort)
worker.terminate()
if (error) reject(error)
else resolve(new File([blob], 'upload.jpg', { type: 'image/jpeg' }))
}
const abort = () => finish(new Error('Image preparation canceled.'))
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Image preparation timed out.')), 30_000)
worker.onerror = () => finish(new Error('Image worker failed.'))
worker.onmessageerror = () => finish(new Error('Invalid worker message.'))
worker.onmessage = ({ data }) => {
if (
!(data?.blob instanceof Blob) ||
data.blob.size === 0 ||
data.blob.type !== 'image/jpeg'
) {
finish(new Error('Image preparation failed.'))
return
}
finish(null, data.blob)
}
try {
worker.postMessage(file)
} catch {
finish(new Error('Could not start image preparation.'))
}
})
}
Consulte a documentação da plataforma para OffscreenCanvas.convertToBlob e Worker.terminate.
Como garantir a segurança durante uploads de arquivos
1. Valide os tipos e tamanhos dos arquivos
As funções de upload aceitam arquivos de até 10 MiB. Tipos MIME, nomes e tamanhos fornecidos pelo cliente são metadados não confiáveis. O servidor deve limitar os bytes de forma independente, identificar conteúdo compatível e aplicar a política de varredura de segurança ou decodificação antes de disponibilizar um upload.
2. Use armazenamento seguro de arquivos
Os exemplos de navegador a seguir exigem uma aplicação existente, autenticada e de mesma origem,
com um endpoint tus em /api/tus/files/. Este tutorial trata do cliente; ele não
provisiona esse servidor. O servidor tus para Node.js, que recebe
manutenção, fornece implementações de protocolo e armazenamento; o gateway da sua aplicação deve
fornecer a sessão e a política de publicação. Configure o gateway para validar a sessão e
X-CSRF-TOKEN nas operações de alteração e verificar
X-Upload-Owner em relação à sessão em cada requisição tus. Imponha um total de
10 MiB, blocos de 1 MiB, limites de armazenamento e de taxa por usuário, expiração e armazenamento
temporário privado. Verifique a propriedade na criação, em HEAD, em PATCH e em qualquer requisição
de encerramento. Rejeite redirecionamentos e retorne apenas URLs Location de mesma origem sob
/api/tus/files/.
Depois que tus informa a conclusão, POST /api/upload-publications aceita
{uploadUrl}. O servidor deve resolver essa URL somente com base nos seus próprios
registros de upload com propriedade verificada, sem nunca buscar uma URL arbitrária. Ele verifica
o recebimento completo do upload e o conteúdo e, em seguida, publica uma única vez de forma atômica;
requisições duplicadas retornam o mesmo comprovante com HTTP 200 ou 201. Uma resposta sem sucesso
ou 202 não confirma a publicação. Retorne {id} contendo um ID de
comprovante opaco e não vazio. O acesso para download deve autorizar separadamente o usuário que
faz a requisição. Nomes de arquivo aleatórios, por si só, não fornecem controle de acesso.
Implementação de uploads retomáveis com tus
O nome do protocolo é tus. Instale a versão testada do cliente no seu projeto para navegador:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact tus-js-client@4.3.1 &&
yarn add --dev --exact esbuild@0.28.2)
Crie upload.js. Passe o ID atual do proprietário e o token CSRF da sua página
autenticada; nenhum deles é um Auth Secret da Transloadit. Esta função faz novas tentativas após
interrupções dentro de uma única sessão da página. Ela não associa automaticamente um arquivo
recém-selecionado a um upload anterior com base apenas no nome. Um upload abortado ou que falhou
expira no servidor; uma nova chamada cria um novo upload.
import { Upload } from 'tus-js-client'
export async function uploadFile(file, { owner, csrf, signal, onProgress }) {
signal.throwIfAborted()
if (
!(file instanceof Blob) ||
file.size === 0 ||
file.size > 10 * 1024 * 1024 ||
!owner ||
!csrf
) {
throw new Error('Choose a file between 1 byte and 10 MiB while signed in.')
}
const headers = { 'X-CSRF-TOKEN': csrf, 'X-Upload-Owner': owner }
const uploadUrl = await new Promise((resolve, reject) => {
let settled = false
let timer
const finish = (error, url) => {
if (settled) return
settled = true
clearTimeout(timer)
signal.removeEventListener('abort', abort)
if (error) {
void upload.abort().catch(() => {})
reject(error)
} else resolve(url)
}
const abort = () => finish(new Error('Upload canceled.'))
const upload = new Upload(file, {
endpoint: '/api/tus/files/',
headers,
httpStack: {
getName() { return 'SameOriginFetch' },
createRequest(method, url) {
const controller = new AbortController()
const requestHeaders = new Headers()
return {
getMethod() { return method },
getURL() { return url },
getUnderlyingObject() { return controller },
setHeader(name, value) { requestHeaders.set(name, value) },
getHeader(name) { return requestHeaders.get(name) },
setProgressHandler() {},
async abort() { controller.abort() },
async send(body) {
const response = await fetch(url, {
method, body, headers: requestHeaders,
credentials: 'same-origin', redirect: 'error', signal: controller.signal,
})
const text = response.ok ? await response.text() : ''
return {
getStatus() { return response.status },
getHeader(name) { return response.headers.get(name) },
getBody() { return text },
getUnderlyingObject() { return response },
}
},
}
},
},
chunkSize: 1024 * 1024,
storeFingerprintForResuming: false,
retryDelays: [0, 1000, 3000],
onShouldRetry(error) {
const status = error.originalResponse?.getStatus() ?? 0
return status === 0 || status === 409 || status === 423 || status === 429 || status >= 500
},
onBeforeRequest(request) {
const url = new URL(request.getURL(), location.href)
if (url.origin !== location.origin || !url.pathname.startsWith('/api/tus/files/')) {
throw new Error('Unexpected upload URL')
}
},
onProgress(loaded, total) {
try {
onProgress(Math.min(99, Math.floor((100 * loaded) / total)))
} catch {
finish(new Error('Progress display failed.'))
}
},
onError() {
finish(new Error('Upload failed.'))
},
onSuccess() {
if (!upload.url) finish(new Error('Missing upload URL.'))
else finish(null, upload.url)
},
})
signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(() => finish(new Error('Upload timed out.')), 120_000)
try {
upload.start()
} catch {
finish(new Error('Could not start upload.'))
}
})
signal.throwIfAborted()
const response = await fetch('/api/upload-publications', {
method: 'POST',
credentials: 'same-origin',
redirect: 'error',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadUrl }),
signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
})
if (!response.ok || ![200, 201].includes(response.status)) {
throw new Error(`Publication not confirmed (HTTP ${response.status}).`)
}
const receipt = await response.json()
if (typeof receipt.id !== 'string' || !receipt.id) throw new Error('Invalid publication receipt.')
onProgress(100)
return receipt.id
}
Isso usa as opções e os callbacks reais do tus-js-client. Seu pequeno transporte baseado em Fetch implementa a interface do stack HTTP do cliente para recusar redirecionamentos antes de segui-los. O progresso avança nos limites dos blocos tus, em vez de acompanhar os bytes em trânsito. Após a perda de uma resposta, o navegador pode reenviar uma requisição antes que tus reconcilie o deslocamento confirmado; o servidor deve rejeitar um deslocamento desatualizado sem acrescentar bytes duplicados. O prazo de dois minutos limita toda a transferência, incluindo novas tentativas. A perda de uma resposta de publicação deixa o resultado incerto: consulte a lista de uploads do seu servidor antes de iniciar outro upload. Uma conexão lenta pode atingir esse prazo mesmo com menos de 10 MiB. A publicação final tem um prazo separado de 30 segundos. O cancelamento interrompe o trabalho do cliente, mas não comprova que o servidor excluiu ou deixou de disponibilizar um upload; o armazenamento temporário abandonado deve expirar no servidor.
Para uma integração completa com a página, renderize os atributos
data-upload-owner e data-csrf com os valores escapados em
<html> a partir da sua página com sessão e adicione:
<label>File <input id="file" type="file" /></label>
<label><input id="resize" type="checkbox" /> Prepare JPEG or PNG as JPEG</label>
<button id="upload" type="button">Upload</button>
<button id="cancel" type="button">Cancel</button>
<progress id="progress" aria-label="Upload progress" max="100" value="0"></progress>
<p id="status" role="status"></p>
<script type="module" src="/main.js"></script>
Crie main.js:
import { prepareImage } from './prepare-image.js'
import { uploadFile } from './upload.js'
const input = document.getElementById('file')
const button = document.getElementById('upload')
const status = document.getElementById('status')
const resize = document.getElementById('resize')
let controller = null
button.onclick = async () => {
if (controller || !input.files?.[0]) return
controller = new AbortController()
button.disabled = true
input.disabled = true
resize.disabled = true
document.getElementById('progress').value = 0
status.textContent = 'Preparing upload…'
try {
let file = input.files[0]
if (resize.checked) {
file = await prepareImage(file, controller.signal)
}
await uploadFile(file, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: controller.signal,
onProgress(value) {
document.getElementById('progress').value = value
},
})
status.textContent = 'Upload published.'
} catch {
status.textContent = 'Upload failed or canceled. Check your uploads before retrying.'
} finally {
controller = null
button.disabled = false
input.disabled = false
resize.disabled = false
}
}
document.getElementById('cancel').onclick = () => controller?.abort()
Gere o bundle do ponto de entrada principal e sirva o worker ao lado dele na mesma origem HTTPS:
(command -v node >/dev/null && command -v yarn >/dev/null && command -v cp >/dev/null &&
yarn esbuild main.js --bundle --format=esm --tsconfig-raw='{}' --outfile=public/main.js &&
cp image-worker.js public/image-worker.js)
Uso do Uppy para uma experiência fluida
O Uppy fornece uma interface para seleção, restrições, progresso e cancelamento. Para este gateway,
use a interface de upload personalizada do Uppy para chamar a mesma função
uploadFile. Isso mantém a publicação como parte do sucesso e usa o mesmo prazo,
os mesmos cabeçalhos de autenticação e o mesmo tratamento de falhas. Este é um ponto de entrada
alternativo, não um componente de upload adicional para montar ao lado da página anterior.
Instale pacotes compatíveis:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn add --exact @uppy/core@6.2.0 @uppy/dashboard@6.0.1)
Crie uppy-main.js e monte-o em uma página com
<div id="drag-drop-area"></div> e os mesmos atributos de sessão. A aplicação deve chamar a função de
limpeza retornada ao remover essa interface.
import Uppy from '@uppy/core'
import Dashboard from '@uppy/dashboard'
import '@uppy/core/css/style.min.css'
import '@uppy/dashboard/css/style.min.css'
import { uploadFile } from './upload.js'
export function mountUploader() {
const uppy = new Uppy({
restrictions: { maxNumberOfFiles: 1, maxFileSize: 10 * 1024 * 1024, minFileSize: 1 },
}).use(Dashboard, { inline: true, target: '#drag-drop-area' })
let active = null
let destroyed = false
const cancel = () => {
const previous = active
active = null
previous?.controller.abort()
}
uppy.on('cancel-all', cancel)
uppy.on('file-removed', (file) => {
if (active?.fileId === file.id) cancel()
})
uppy.addUploader(async (ids) => {
if (active) throw new Error('An upload is already running.')
const file = uppy.getFile(ids[0])
if (!file || !(file.data instanceof Blob)) throw new Error('Select a local file.')
const operation = { fileId: file.id, controller: new AbortController() }
const uploadStarted = Date.now()
active = operation
const current = () => !destroyed && active === operation && Boolean(uppy.getFile(file.id))
uppy.emit('upload-start', [file])
try {
const id = await uploadFile(file.data, {
owner: document.documentElement.dataset.uploadOwner,
csrf: document.documentElement.dataset.csrf,
signal: operation.controller.signal,
onProgress(value) {
if (!current()) return
uppy.emit('upload-progress', file, {
uploadStarted,
bytesUploaded: Math.floor((file.data.size * value) / 100),
bytesTotal: file.data.size,
})
},
})
if (current()) uppy.emit('upload-success', file, { status: 200, body: { id } })
} catch {
if (!current()) return
uppy.emit('upload-error', file, new Error('Upload not confirmed. Check your uploads.'))
throw new Error('Upload not confirmed.')
} finally {
if (active === operation) active = null
}
})
return () => {
destroyed = true
cancel()
uppy.destroy()
}
}
let cleanup = mountUploader()
window.addEventListener('pagehide', () => cleanup())
window.addEventListener('pageshow', (event) => {
if (event.persisted) cleanup = mountUploader()
})
Use esta marcação em vez dos controles e do script da página básica, com os mesmos atributos de
sessão em <html>:
<link rel="stylesheet" href="/uppy-main.css" />
<div id="drag-drop-area"></div>
<script type="module" src="/uppy-main.js"></script>
Compile o ponto de entrada alternativo:
(command -v node >/dev/null && command -v yarn >/dev/null &&
yarn esbuild uppy-main.js --bundle --format=esm --tsconfig-raw='{}' --outfile=public/uppy-main.js)
O cancelamento encerra a operação atual antes de aceitar outro arquivo, para que callbacks tardios
não possam marcar a operação substituta como falha nem limpar sua referência de cancelamento.
Ao ocorrer pagehide, esta integração destrói o Dashboard e cancela o trabalho
ativo do cliente; uma restauração da página preservada monta um Dashboard vazio. Selecione o
arquivo novamente após retornar. Uploads de provedores remotos precisam de uma integração
Companion autenticada separada; este exemplo aceita apenas Blobs locais. Consulte a
API de upload do Uppy.
Confirme o resultado publicado
Na página básica, escolha um arquivo, ative opcionalmente a preparação de imagens e selecione Upload. A página aguarda um comprovante válido do servidor antes de exibir Upload published.
O estado correspondente do Uppy é Complete. Uma resposta 202, uma falha na validação ou um tempo limite deixa o upload sem confirmação, mesmo quando todos os bytes foram enviados. Consulte a lista autenticada de uploads antes de tentar novamente uma publicação incerta; uma nova chamada cria um novo armazenamento temporário.
Para um arquivo não preparado, compare a contagem de bytes armazenados e o SHA-256 com o arquivo selecionado usando as ferramentas de inspeção autorizadas da sua aplicação. Para uma imagem preparada, inspecione o JPEG resultante: nenhuma dimensão excede 1024, a proporção é preservada dentro dos limites do arredondamento e as regiões transparentes têm fundo branco. O redimensionamento e a codificação JPEG podem aumentar o tamanho de um PNG pequeno, então meça o tamanho preparado e a qualidade visual antes de ativar a preparação por padrão.
Recursos adicionais
Respostas a perguntas comuns
Como confirmar um upload? A contagem de bytes de uma transferência não basta. Confirme um comprovante autenticado do servidor após a validação e a publicação. A análise de malware específica de cada provedor é uma integração separada; não existe um comando CLI universal para verificação de uploads.
O que é um upload de arquivo sem restrições? É um endpoint de upload que aceita conteúdo sem restrições adequadas, podendo permitir o armazenamento ou a execução de arquivos perigosos. Valide no servidor e isole o armazenamento do conteúdo web executável.
Qual codificação de formulário HTML permite uploads de arquivos? Um formulário tradicional usa
method="post" e enctype="multipart/form-data", com um campo de seleção de arquivo
que tenha nome. Ao enviar FormData com Fetch ou Axios no navegador, deixe que
o navegador defina o delimitador multipart; não invente o cabeçalho por conta própria.
