Destino Dev

Blog

CSV streaming no browser com Web Workers, Transferable Streams e AbortSignal

Por John Pires · 13 de março de 2026

CSV streaming no browser com Web Workers, Transferable Streams e AbortSignal

1) Problema real que resolve

Contexto e dor

Aplicações web frequentemente precisam processar arquivos CSV grandes — dezenas ou centenas de megabytes — vindos de uma API ou upload do usuário. Fazer parsing síncrono desses arquivos no thread principal congela a UI, causa jank e pode até estourar o limite de memória. Precisamos de uma estratégia que: permita processar CSV em streaming, libere o thread principal, permita cancelamento responsivo e minimize cópias de memória.

O que queremos entregar

Queremos um pipeline que:

  • Faça download/parsing incremental do CSV.
  • Execute parsing e transformação em um Web Worker para não bloquear a UI.
  • Utilize Transferable Streams para mover o fluxo de dados entre worker e main thread sem cópias desnecessárias.
  • Suporte cancelamento com AbortSignal de forma responsiva.

O objetivo é manter memória e CPU sob controle enquanto oferecemos uma API simples para o restante da aplicação consumir linhas ou objetos JSON já parseados.

2) Abordagem ingênua vs abordagem otimizada

Abordagem ingênua (e seus problemas)

Um caminho comum é: baixar todo o CSV como string, chamar split('\n') e parsear em memória. Isso funciona para arquivos pequenos, mas falha em produção. Problemas práticos:

  • Memória: todo o arquivo fica na heap do JS.
  • UI bloqueada: parsing e split pesados no main thread causam jank.
  • Sem cancelamento limpo: cancelar após download exige descartar uma grande string.

Abordagem otimizada

Arquitetura proposta:

  1. Fazer fetch/streaming do CSV dentro de um Web Worker para evitar bloqueio.
  2. No Worker, usar ReadableStream e TextDecoder para transformar bytes em linhas incrementalmente.
  3. Gerar um ReadableStream de objetos (linhas parseadas) e transferi-lo para o main thread usando postMessage com streams transferíveis.
  4. Usar MessageChannel para sinalizar abort/controle entre main e worker; o worker usa um AbortController interno para cancelar o fetch.

Vantagens:

  • Zero cópias grandes — Transferable Streams move o stream, não os bytes.
  • Cancelamento rápido — main envia comando; worker aborta fetch e fecha o stream.
  • Processamento paralelo — parsing no worker, UI responde.

3) Implementacao com codigo real

Visão geral do fluxo

Arquivos principais:

  • main.js — cria worker, recebe o ReadableStream de linhas, consome com reader ou pipeTo.
  • csv-worker.js — faz fetch, parse incremental, cria ReadableStream de objetos e transfere via MessagePort.

Exemplo 1: main.js — iniciar worker, receber stream e controlar abort

O bloco abaixo cria um Worker, estabelece um MessageChannel para controle/dados e instrui o worker a iniciar o download. Aqui o main recebe o ReadableStream já parseado (objetos) e consome via getReader().

// main.js
// Cria o worker (arquivo csv-worker.js deve estar disponível)
const worker = new Worker('csv-worker.js', { type: 'module' });

// MessageChannel para controle e transferência do stream
const channel = new MessageChannel();
const controlPort = channel.port1; // fica no main
const workerPort = channel.port2;  // será transferido para o worker

// Quando o worker nos enviar o stream via postMessage no controlPort, vamos consumi-lo
controlPort.onmessage = async (ev) => {
  if (ev.data && ev.data.stream) {
    const stream = ev.data.stream; // ReadableStream que vem do worker
    // Consumimos usando reader para processar linha a linha
    const reader = stream.getReader();
    try {
      while (true) {
        const { value, done } = await reader.read();
        if (done) break;
        // value é um objeto representando a linha parseada
        console.log('linha parseada', value);
      }
    } finally {
      reader.releaseLock();
    }
  }
  if (ev.data && ev.data.error) {
    console.error('Worker error:', ev.data.error);
  }
};

// Inicia o processo: passamos URL + port para o worker (transferindo o port)
const csvUrl = '/api/large.csv';
worker.postMessage({ cmd: 'start', url: csvUrl, port: workerPort }, [workerPort]);

// Exemplo de como abortar: manda controle pelo controlPort
function abortStream() {
  controlPort.postMessage({ cmd: 'abort' });
}

// expose abort para debug
window.abortCsvStream = abortStream;

O que acontece: o main cria um MessageChannel, transfere um terminal para o worker e espera mensagens nesse terminal. Quando receber o ReadableStream, ele percorre as linhas parseadas sem travar a UI.

Exemplo 2: csv-worker.js — fetch, parse e transferir ReadableStream

O worker recebe a mensagem com a url e o MessagePort. Ele faz fetch com um AbortController que pode ser acionado via mensagens de controle. O worker cria um ReadableStream de objetos (cada objeto representa uma linha parseada) e posta esse stream de volta através do MessagePort — transferindo-o.

// csv-worker.js
self.onmessage = (ev) => {
  const { cmd, url, port } = ev.data || {};
  if (cmd === 'start' && url && port) {
    // ouvimos comandos vindos do main via port
    port.onmessage = (e) => {
      if (e.data && e.data.cmd === 'abort') {
        if (ac) ac.abort();
      }
    };
    // inicia o trabalho
    startFetchAndStream(url, port).catch(err => {
      port.postMessage({ error: err.message });
    });
  }
};

let ac; // AbortController no escopo do worker

async function startFetchAndStream(url, port) {
  ac = new AbortController();
  const res = await fetch(url, { signal: ac.signal });
  if (!res.body) throw new Error('Resposta sem body streaming');

  // cria um ReadableStream de objetos parseados e transfere para o main
  const parsedStream = createCsvObjectStream(res.body, { signal: ac.signal });

  // Transferimos o stream pelo MessagePort para evitar cópias
  port.postMessage({ stream: parsedStream }, [parsedStream]);
}

// Função que cria um ReadableStream de objetos a partir do ReadableStream de bytes
function createCsvObjectStream(byteStream, opts = {}) {
  const decoder = new TextDecoder();
  let buffer = '';
  const textReader = byteStream.getReader();
  const header = { raw: null };

  return new ReadableStream({
    async pull(controller) {
      const { value, done } = await textReader.read();
      if (done) {
        if (buffer.length) {
          pushLine(buffer, controller, header);
          buffer = '';
        }
        controller.close();
        return;
      }
      buffer += decoder.decode(value, { stream: true });
      let idx;
      while ((idx = buffer.indexOf('\n')) !== -1) {
        const line = buffer.slice(0, idx).replace(/\r$/, '');
        buffer = buffer.slice(idx + 1);
        pushLine(line, controller, header);
      }
      // checa abort signal se disponível
      if (opts.signal && opts.signal.aborted) {
        controller.error(new DOMException('aborted', 'AbortError'));
      }
    },
    cancel(reason) {
      try { textReader.cancel(reason); } catch (e) { /* noop */ }
    }
  });
}

function pushLine(line, controller, header) {
  if (header.raw === null) {
    // primeira linha: cabeçalho
    header.raw = line;
    header.cols = line.split(',').map(s => s.trim());
    return;
  }
  if (!line) return; // pular linhas vazias
  // parsing simples: split por vírgula — troque por parser robusto se necessário
  const values = line.split(',');
  const obj = {};
  for (let i = 0; i < header.cols.length; i++) obj[header.cols[i]] = values[i] ?? null;
  controller.enqueue(obj);
}

O que acontece: o worker faz fetch streaming, decodifica bytes em texto incrementalmente, separa linhas e enfileira objetos no ReadableStream. Em seguida, transfere o stream para o main thread via MessagePort. O worker também escuta uma mensagem 'abort' para chamar ac.abort().

Exemplo 3: consumir o stream com pipeTo para processamento paralelo

Exemplo de como podemos encadear pipes no main: transformar objetos em JSON lines e enviar para um serviço IndexedDB/arquivo sem materializar tudo na memória.

// no main.js, depois de receber 'stream' pelo controlPort
const jsonLineTransform = new TransformStream({
  transform(obj, controller) {
    controller.enqueue(new TextEncoder().encode(JSON.stringify(obj) + '\n'));
  }
});

// Suponha que temos uma writableStream para armazenamento (ex: file-writable, indexeddb)
// Aqui apenas demonstramos pipeTo para /dev/null simulado
const sink = new WritableStream({
  write(chunk) { /* armazena o chunk */ },
  close() { console.log('sink fechado'); }
});

// streamFromWorker.pipeThrough(jsonLineTransform).pipeTo(sink);
// Isso processa em streaming sem criar arrays gigantes

O que acontece: usamos pipeThrough/pipeTo para montar um pipeline de transformação/consumo eficiente. Cada etapa pode processar dados em paralelo com backpressure automática.

4) Testes e validacao

Testes manuais e ferramentas

Testar streaming é diferente de testar operações síncronas. Estratégias:

  • Testes manuais com arquivos grandes (ex.: 100MB) para avaliar uso de memória e UI.
  • Perf trace: use Chrome DevTools Performance para validar ausência de long tasks no main thread.
  • Simule cancelamento: acione abort via port e confirme que fetch foi cancelado no worker e que o stream fecha corretamente.

Exemplo de teste automatizado (pseudocódigo)

Você pode usar um teste que cria um servidor local que entrega CSV em chunks lentos e assert que as linhas são consumidas incrementalmente:

// Pseudocódigo de teste (node + playwright / puppeteer)
// 1) iniciar servidor que envia CSV em blocos com delays
// 2) abrir página que usa main.js para iniciar worker
// 3) esperar N linhas processadas e então enviar abort
// 4) garantir que worker fechou e que não houve erro de memória

// Asserts importantes:
// - Primeiro chunk produzido antes do download completo
// - Abort cancela a operação e não deixa handles pendentes

Testes automatizados focados em latência e cancelamento detectam regressões que testes unitários tradicionais não pegam.

Erros comuns e armadilhas

Listei os casos que mais geram suporte:

  • Transferência inválida: tentar transferir um stream já lido no main thread vai falhar. Só transfira uma vez.
  • Signals mal gerenciadas: não confie em passar AbortController entre contextos; prefira enviar comandos via MessagePort e ter um AbortController no worker.
  • Parsing superficial: split(',') quebra quando campos têm vírgulas entre aspas. Para CSV real use um parser robusto (p.ex. papaparse transformado para streaming).
Evite parsers síncronos pesados no worker — mesmo no worker você pode criar micro-blocks gastando muito CPU. Considere chunking de processamento para manter responsividade

5) Consideracoes de producao

Observabilidade e métricas

Em produção monitore:

  • Latency até primeira linha processada (TTFF — time to first fragment).
  • Tempo total de processamento.
  • Taxa de consumo (linhas/segundo).
  • Eventos de abort e erros no worker.

Compatibilidade e fallback

Nem todos os navegadores suportam Transferable Streams de forma idêntica. Estratégia pragmática:

  1. Feature-detect: tente transferir stream via postMessage; se falhar, caia para um fallback onde o worker envia chunks JSON via postMessage normal (menor performance, maior cópia).
  2. Mantenha parse simples no worker e permita implantação de um parser mais robusto se necessário.

Implementação do fallback (esboço): o worker lê e envia via port.postMessage({chunk}) em vez de transferir o stream, e o main acumula ou processa por chunk.

Segurança e limites

Tenha atenção a payloads malformados e proteção contra ataques:

  • Limite o número de linhas processadas por hora por origem se necessário.
  • Valide e sanitize campos críticos antes de armazenar/mostrar.
  • Proteja endpoints que servem CSV para evitar downloads arbitrários de dados sensíveis.

Conclusao

Streaming de CSV com Web Workers, Transferable Streams e um modelo de controle baseado em mensagens (abort via MessagePort) resolve problemas reais de performance e UX. A solução move parsing pesado para fora do thread principal, reduz cópias por meio de streams transferíveis e oferece cancelamento responsivo.

Implementar essa arquitetura exige atenção a parsing robusto, teste com arquivos grandes e fallback para navegadores sem suporte a transferência de streams. Com esses cuidados, você obtém um pipeline escalável e responsivo para processar CSV em aplicações web modernas.

Resumo prático:

  • Execute fetch/parse no worker; transfira um ReadableStream em vez de strings massivas.
  • Use MessageChannel para controle (abort) e para transferir streams com menor overhead.
  • Adote testes de integração que simulem latência e cancelamento.

Implementar essa abordagem em sua base de código melhora eficiência e experiência do usuário ao lidar com grandes volumes de dados tabulares.

Assuntos: JavaScript, web-workers, streams, csv, abortsignal

Leia também