Busca no navegador com DuckDB-WASM, sem backend
Vitor Ayres (abre em nova aba)
O contém. é uma busca de produtos regularizados na ANVISA: alimentos, suplementos e saneantes, por número de processo, CNPJ, registro, nome, marca ou empresa. Ele não tem servidor. A página é estática, os dados são arquivos Parquet publicados todo dia, e a consulta roda no navegador com o DuckDB-WASM.
Este texto registra como o plano de ler só o pedaço necessário de cada arquivo, por HTTP Range, falhou no GitHub Pages, e o que fizemos no lugar. As medições são de 6 de outubro de 2026, com a versão 1.33.1-dev57.0 do DuckDB-WASM.
Resumo
- Site estático no GitHub Pages. O DuckDB-WASM consulta Parquet dentro do navegador.
- O plano era ler por HTTP Range, e isso falhou de três jeitos no Pages.
- Uma busca por texto via Range transferiu 5,3 MB de um arquivo de 3,25 MB.
- No Chrome, um fetch com Range deixou o fetch seguinte com um corpo de 1 byte.
- A solução: baixar cada arquivo inteiro, conferir o tamanho e a assinatura, e registrar como buffer.
- Reordenar a tabela de detalhe fez a busca de um produto tocar um grupo de linhas. O arquivo foi de 1,57 para 1,74 MB.
- A primeira visita baixa cerca de 5 MB de motor mais as tabelas. Depois, zero rede.
De onde vêm os dados
Os arquivos vêm do pipeline descrito no post sobre os dados abertos da ANVISA: um processo diário converte os CSVs da ANVISA em Parquet e publica um manifest.json com o caminho, as linhas, os bytes e o SHA-256 de cada tabela.
O site só conhece o manifesto. Os caminhos mudam a cada build e são imutáveis, então um 404 num caminho de dados quer dizer que houve deploy no meio da sessão. Nesse caso o site relê o manifesto sem cache e tenta uma vez com o caminho novo.
Ele usa quatro tabelas: alimentos, o detalhe das apresentações de alimentos, saneantes e produtos irregulares. Cosméticos, com 24 MB, ficou de fora por enquanto.
| Tabela | Parquet |
|---|---|
| Alimentos | 3,25 MB |
| Detalhe das apresentações | 1,74 MB |
| Saneantes | 3,7 MB |
| Produtos irregulares | 1,1 MB |
O plano: ler só o pedaço necessário
O Parquet é feito para isso. O arquivo termina num rodapé que diz onde cada grupo de linhas está e o mínimo e o máximo de cada coluna no grupo. Um leitor que suporta HTTP Range lê o rodapé, descobre quais grupos interessam e baixa só esses bytes. Com os arquivos ordenados por CNPJ em grupos de 8.192 linhas, uma busca por CNPJ leria o rodapé e um grupo.
Num teste direto contra o GitHub Pages, um pedido com Range respondeu 206, com Content-Range, Access-Control-Allow-Origin: * e cache de 10 minutos. Parecia resolvido.
Três jeitos de falhar no GitHub Pages
Em produção, o leitor HTTP do DuckDB-WASM quebrou de formas que o servidor local não reproduzia.
O HEAD com Range responde 200. O DuckDB-WASM tem um modo reliableHeadRequests que pergunta o tamanho do arquivo com um HEAD. O Pages responde esse HEAD com 200 e o tamanho inteiro, em vez de 206, e o modo falha ao abrir o arquivo.
O Firefox falha no GET com Range. O leitor HTTP do worker é um XHR síncrono. Contra o Pages, no Firefox, o GET com Range termina em “NetworkError: A network error occurred”. No Chrome 154, o mesmo pedido funciona.
Uma busca por texto lê mais que o arquivo. Uma busca por nome toca todos os grupos de linhas, e as respostas parciais do XHR não entram no cache. A cada carregamento, a busca transferiu 5,3 MB para um arquivo de 3,25 MB.
E mais quatro detalhes que medimos no caminho:
- Nessa versão, o leitor só faz Range com
forceFullHTTPReads: falsedefinido de forma explícita. Sem isso, ele baixa o arquivo inteiro a cada abertura. - A resposta inteira vem comprimida com gzip, 3.218.370 bytes no fio para um arquivo de 3.254.645. A resposta com Range vem sem compressão.
- No Chrome, depois de um
fetchcomRange: bytes=0-0, ofetchsimples seguinte da mesma URL devolve 200 com um corpo de 1 byte. Só o terceiro vem inteiro. Um visitante de uma página que lê por Range pode receber um arquivo truncado numa leitura posterior. - Um parâmetro preparado num
BETWEENde inteiros impediu o descarte de grupos: a consulta de detalhe leu 1,5 MB com?e cerca de 1 MB com literais. E mesmo quando o descarte funciona, o leitor lê adiante até o fim do arquivo.
A solução: baixar o arquivo inteiro e conferir
Os arquivos são pequenos de propósito, então o download inteiro é mais simples e mais barato que o Range. O worker do DuckDB não faz HTTP nenhum.
- A página faz
fetch()de cada tabela na primeira vez que a seção abre. O progresso usa o total do manifesto, porque oContent-Lengthé o do corpo comprimido. - O tamanho recebido tem que ser igual ao campo
bytesdo manifesto, e os últimos quatro bytes têm que serPAR1. Um Parquet truncado não passa. - Se não bater, o site baixa de novo com
cache: "reload", ignorando o cache HTTP. - O buffer é registrado no DuckDB com
registerFileBuffer, com o id do build no nome, e vira umaVIEW. Um build novo não sobrescreve o que uma consulta em andamento está lendo.
Depois disso, toda consulta é local. O caminho é imutável, então o cache HTTP do navegador guarda o arquivo até o próximo build.
Dois detalhes de compatibilidade: o site usa os bundles mvp e eh do DuckDB-WASM, porque o coi, com threads, exige cabeçalhos COOP e COEP que o Pages não envia. E as datas voltam como texto, via strftime no SQL, porque o tipo temporal do Arrow muda conforme a versão da biblioteca.
Ordenar o arquivo para quem lê por HTTP
O Range ainda importa para quem lê os mesmos arquivos com o DuckDB nativo, direto da URL. Ali medimos duas coisas.
Só a primeira chave de ordenação descarta grupos. A tabela de alimentos é ordenada por CNPJ e depois por processo, então uma busca por CNPJ lê um grupo e uma busca por processo lê o arquivo todo.
A tabela de detalhe tinha grupos desiguais, de 92 a 411 KB, porque os produtos recentes trazem a tabela nutricional em texto longo. A única consulta do site nessa tabela é “todas as apresentações de um produto”. Reordenada por produto, em grupos de 2.048 linhas, ela ficou com 33 grupos, mediana de 25 KB e máximo de 117 KB. Uma busca de produto toca um grupo, no máximo dois entre 50 mil produtos. O arquivo cresceu de 1,57 para 1,74 MB.
Primeira visita e atualização
A primeira visita baixa cerca de 5 MB do motor, servido pelo jsDelivr, mais a tabela da seção aberta. Depois, os arquivos ficam no cache.
O site se refaz à noite, três vezes, às 22:30, 01:30 e 04:30 UTC. Ele compara o id do build no manifesto com o que o site usou e só publica quando mudou. O cron existe só para o dicionário de dados e a página “sobre”, gerados no build. A busca lê o manifesto ao vivo e não depende de republicar.
Como verificamos
As medições são de 6 de outubro de 2026, com @duckdb/duckdb-wasm 1.33.1-dev57.0, no Chrome 154 e no Firefox, contra o GitHub Pages e contra um build local dos dados. Os dados foram conferidos de novo no build publicado naquele dia. Para reproduzir o caminho do Pages em desenvolvimento, usamos um servidor Node de 61 linhas que responde CORS, HEAD e Range. O http.server do Python não faz nenhum dos três.
O que não medimos: a versão do Firefox não foi anotada. O envenenamento do cache foi reproduzido só no Chrome. Não testamos Range num servidor que não fosse o Pages, e não medimos tempos de carregamento.
O site não tem vínculo com a ANVISA. O código está em anvisa-dash, com licença MIT.
Uma consulta no navegador, sem servidor e sem enviar dado nenhum, é uma das formas de falar com os seus dados que entregamos. E manter um site assim, aberto e com dados públicos, é parte do nosso trabalho em código aberto.
Perguntas
- Uma busca com DuckDB-WASM precisa de backend?
- Não. O navegador baixa o motor, cerca de 5 MB, e os arquivos Parquet. Depois disso nenhuma consulta usa a rede. O site é estático no GitHub Pages.
- Por que não ler os Parquet por HTTP Range no GitHub Pages?
- Falhou de três jeitos: o Pages responde HEAD com Range usando 200, o Firefox falha no GET com Range, e uma busca por texto transferiu 5,3 MB de um arquivo de 3,25 MB.
- Qual é o tamanho dos arquivos que o navegador baixa?
- Alimentos 3,25 MB, o detalhe das apresentações 1,74 MB, saneantes 3,7 MB e produtos irregulares 1,1 MB. Cada um é baixado na primeira vez que a seção abre, e fica no cache HTTP.
- Como saber que o arquivo chegou inteiro?
- O tamanho tem que bater com o campo bytes do manifesto, e os últimos quatro bytes têm que ser PAR1, a assinatura do Parquet. Se não bater, o site baixa de novo ignorando o cache.
- Funciona no Firefox?
- Sim, com o download inteiro por fetch. Com HTTP Range, o Firefox falhou contra o GitHub Pages com um NetworkError, enquanto o Chrome funcionava.