O cache de bytecode é uma otimização em tempo de build que melhora dramaticamente o tempo de inicialização de aplicações pré-compilando seu JavaScript para bytecode. Por exemplo ao compilar o tsc do TypeScript com bytecode habilitado o tempo de inicialização melhora em 2x.
Uso
Uso básico
Habilite o cache de bytecode com a flag --bytecode:
bun build ./index.ts --target=bun --bytecode --outdir=./distIsso gera dois arquivos:
dist/index.js- Seu JavaScript empacotadodist/index.jsc- O arquivo de cache de bytecode
Em tempo de execução o Bun detecta e usa automaticamente o arquivo .jsc:
bun ./dist/index.js # Usa automaticamente index.jscCom executáveis autônomos
Ao criar executáveis com --compile o bytecode é incorporado no binário:
bun build ./cli.ts --compile --bytecode --outfile=mycliO executável resultante contém tanto o código quanto o bytecode oferecendo desempenho máximo em um único arquivo.
Combinando com outras otimizações
Bytecode funciona muito bem com minificação e source maps:
bun build --compile --bytecode --minify --sourcemap ./cli.ts --outfile=mycli--minifyreduz o tamanho do código antes de gerar bytecode (menos código -> menos bytecode)--sourcemappreserva o reporte de erros (erros ainda apontam para o código fonte original)--bytecodeelimina overhead de parsing
Impacto de desempenho
A melhoria de desempenho escala com o tamanho do seu codebase:
| Tamanho da aplicação | Melhoria típica de inicialização |
|---|---|
| CLI pequeno (< 100 KB) | 1,5-2x mais rápido |
| App médio-grande (> 5 MB) | 2,5-4x mais rápido |
Aplicações maiores se beneficiam mais porque têm mais código para analisar.
Quando usar bytecode
Ótimo para:
Ferramentas CLI
- Invocadas frequentemente (linters, formatadores, git hooks)
- Tempo de inicialização é toda a experiência do usuário
- Usuários notam a diferença entre 90ms e 45ms de inicialização
- Exemplo: compilador TypeScript, Prettier, ESLint
Ferramentas de build e task runners
- Executados centenas ou milhares de vezes durante o desenvolvimento
- Milissegundos economizados por execução se acumulam rapidamente
- Melhoria na experiência do desenvolvedor
- Exemplo: Scripts de build, executores de teste, geradores de código
Executáveis autônomos
- Distribuídos para usuários que se importam com desempenho ágil
- Distribuição em arquivo único é conveniente
- Tamanho do arquivo menos importante que tempo de inicialização
- Exemplo: CLIs distribuídos via npm ou como binários
Pule para:
- ❌ Scripts pequenos
- ❌ Código que executa uma vez
- ❌ Builds de desenvolvimento
- ❌ Ambientes com restrição de tamanho
- ❌ Código com top-level await (não suportado)
Limitações
Apenas CommonJS
O cache de bytecode funciona atualmente com o formato de saída CommonJS. O bundler do Bun converte automaticamente a maioria do código ESM para CommonJS mas top-level await é a exceção:
// Isso previne cache de bytecode
const data = await fetch("https://api.example.com");
export default data;Por que: Top-level await requer avaliação assíncrona de módulo que não pode ser representada em CommonJS. O grafo de módulos se torna assíncrono e o modelo de função wrapper CommonJS quebra.
Workaround: Mova inicialização assíncrona para uma função:
async function init() {
const data = await fetch("https://api.example.com");
return data;
}
export default init;Agora o módulo exporta uma função que o consumidor pode aguardar quando necessário.
Compatibilidade de versão
Bytecode não é portável entre versões do Bun. O formato de bytecode está atrelado à representação interna do JavaScriptCore que muda entre versões.
Quando você atualiza o Bun deve regenerar o bytecode:
# Após atualizar o Bun
bun build --bytecode ./index.ts --outdir=./distSe o bytecode não corresponder à versão atual do Bun ele é automaticamente ignorado e seu código faz fallback para parsing do código fonte JavaScript. Sua aplicação ainda roda - você apenas perde a otimização de desempenho.
Melhor prática: Gere bytecode como parte do seu processo de CI/CD. Não faça commit de arquivos .jsc no git. Regere-os sempre que atualizar o Bun.
Código fonte ainda necessário
- O arquivo
.js(seu código fonte empacotado) - O arquivo
.jsc(o arquivo de cache de bytecode)
Em tempo de execução:
- Bun carrega o arquivo
.jsvê um pragma@bytecodee verifica o arquivo.jsc - Bun carrega o arquivo
.jsc - Bun valida se o hash do bytecode corresponde ao código fonte
- Se válido Bun usa o bytecode
- Se inválido Bun faz fallback para parsing do código fonte
Bytecode não é ofuscação
Bytecode não obscurece seu código fonte. É uma otimização não uma medida de segurança.
Deploy em produção
Docker
Inclua geração de bytecode no seu Dockerfile:
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build --bytecode --minify --sourcemap \
--target=bun \
--outdir=./dist \
--compile \
./src/server.ts --outfile=./dist/server
FROM oven/bun:1 AS runner
WORKDIR /app
COPY --from=builder /dist/server /app/server
CMD ["./server"]O bytecode é independente de arquitetura.
CI/CD
Gere bytecode durante seu pipeline de build:
# GitHub Actions
- name: Build com bytecode
run: |
bun install
bun build --bytecode --minify \
--outdir=./dist \
--target=bun \
./src/index.tsDebugging
Verifique se o bytecode está sendo usado
Verifique se o arquivo .jsc existe:
ls -lh dist/-rw-r--r-- 1 user staff 245K index.js
-rw-r--r-- 1 user staff 1.1M index.jscO arquivo .jsc deve ser 2-8x maior que o arquivo .js.
Para logar se o bytecode está sendo usado defina BUN_JSC_verboseDiskCache=1 no seu ambiente.
Em sucesso fará log de algo como:
[Disk cache] cache hit for sourceCodeSe você vir um cache miss fará log de algo como:
[Disk cache] cache miss for sourceCodeÉ normal logar um cache miss múltiplas vezes já que o Bun atualmente não faz cache de bytecode de código JavaScript usado em módulos built-in.
Problemas comuns
Bytecode silenciosamente ignorado: Geralmente causado por uma atualização de versão do Bun. A versão do cache não corresponde então o bytecode é rejeitado. Regere para corrigir.
Tamanho de arquivo muito grande: Isso é esperado. Considere:
- Usar
--minifypara reduzir o tamanho do código antes de gerar bytecode - Comprimir arquivos
.jscpara transferência de rede (gzip/brotli) - Avaliar se o ganho de desempenho de inicialização vale o aumento de tamanho
Top-level await: Não suportado. Refatore para usar funções de inicialização assíncronas.
O que é bytecode?
Quando você executa JavaScript o motor JavaScript não executa seu código fonte diretamente. Em vez disso passa por vários passos:
- Parsing: O motor lê seu código fonte JavaScript e converte em uma Árvore de Sintaxe Abstrata (AST)
- Compilação de bytecode: A AST é compilada em bytecode - uma representação de nível mais baixo que é mais rápida de executar
- Execução: O bytecode é executado pelo interpretador do motor ou compilador JIT
Bytecode é uma representação intermediária - é de nível mais baixo que código fonte JavaScript mas de nível mais alto que código de máquina. Pense nisso como linguagem assembly para uma máquina virtual. Cada instrução de bytecode representa uma única operação como "carregar esta variável" "somar dois números" ou "chamar esta função".
Isso acontece toda única vez que você executa seu código. Se você tem uma ferramenta CLI que roda 100 vezes por dia seu código é analisado 100 vezes. Se você tem uma função serverless com cold starts frequentes o parsing acontece em todo cold start.
Com cache de bytecode o Bun move os passos 1 e 2 para o step de build. Em tempo de execução o motor carrega o bytecode pré-compilado e pula direto para execução.
Por que lazy parsing torna isso ainda melhor
Motores JavaScript modernos usam uma otimização inteligente chamada lazy parsing. Eles não analisam todo seu código de início - em vez disso funções são apenas analisadas quando são chamadas pela primeira vez:
// Sem cache de bytecode:
function rarely_used() {
// Esta função de 500 linhas é apenas analisada
// quando é realmente chamada
}
function main() {
console.log("Starting app");
// rarely_used() nunca é chamada então nunca é analisada
}Isso significa que overhead de parsing não é apenas um custo de inicialização - acontece ao longo da vida da sua aplicação conforme diferentes caminhos de código executam. Com cache de bytecode todas as funções são pré-compiladas até mesmo as que são lazy parsed. O trabalho de parsing acontece uma vez em tempo de build em vez de ser distribuído ao longo da execução da sua aplicação.
O formato de bytecode
Dentro de um arquivo .jsc
Um arquivo .jsc contém uma estrutura de bytecode serializada. Entender o que está dentro ajuda a explicar tanto os benefícios de desempenho quanto o tradeoff de tamanho de arquivo.
Seção de cabeçalho (validada em todo load):
- Versão do cache: Um hash atrelado à versão do framework JavaScriptCore. Isso garante que bytecode gerado com uma versão do Bun apenas roda com aquela versão exata.
- Tag de tipo de bloco de código: Identifica se este é um bloco de código Program Module Eval ou Function.
SourceCodeKey (valida se bytecode corresponde ao código fonte):
- Hash do código fonte: Um hash do código fonte JavaScript original. Bun verifica se isso corresponde antes de usar o bytecode.
- Comprimento do código fonte: O comprimento exato do código fonte para validação adicional.
- Flags de compilação: Contexto de compilação crítico como strict mode se é script vs module tipo de contexto eval etc. O mesmo código fonte compilado com diferentes flags produz bytecode diferente.
Instruções de bytecode:
- Stream de instruções: Os opcodes de bytecode reais - a representação compilada do seu JavaScript. Esta é uma sequência de comprimento variável de instruções de bytecode.
- Tabela de metadados: Cada opcode tem metadados associados - coisas como contadores de profiling type hints e contagens de execução (mesmo que ainda não populados).
- Jump targets: Endereços pré-computados para fluxo de controle (if/else, loops, switch statements).
- Tabelas switch: Tabelas de lookup otimizadas para switch statements.
Constantes e identificadores:
- Pool de constantes: Todos valores literais no seu código - números strings booleans null undefined. Estes são armazenados como valores JavaScript reais (JSValues) então não precisam ser analisados do código fonte em tempo de execução.
- Tabela de identificadores: Todos nomes de variáveis e funções usados no código. Armazenados como strings deduplicadas.
- Marcadores de representação de código fonte: Flags indicando como constantes devem ser representadas (como integers doubles big ints etc).
Metadados de função (para cada função no seu código):
- Alocação de registradores: Quantos registradores (variáveis locais) a função precisa -
thisRegister,scopeRegister,numVars,numCalleeLocals,numParameters. - Recursos de código: Um bitmask de características da função - é um construtor? uma arrow function? usa
super? tem tail calls? Estes afetam como a função é executada. - Recursos de escopo léxico: Strict mode e outro contexto léxico.
- Modo de parse: O modo em que a função foi analisada (normal async generator async generator).
Estruturas aninhadas:
- Declarações e expressões de função: Cada função aninhada recebe seu próprio bloco de bytecode recursivamente. Um arquivo com 100 funções tem 100 blocos de bytecode separados todos aninhados na estrutura.
- Handlers de exceção: Blocos try/catch/finally com seus limites e endereços de handler pré-computados.
- Info de expressão: Mapeia posições de bytecode de volta para localizações de código fonte para reporte de erros e debugging.
O que bytecode NÃO contém
Importante bytecode não incorpora seu código fonte. Em vez disso:
- O código fonte JavaScript é armazenado separadamente (no arquivo
.js) - O bytecode apenas armazena um hash e comprimento do código fonte
- Em tempo de load Bun valida se o bytecode corresponde ao código fonte atual
É por isso que você precisa fazer deploy tanto do .js quanto do .jsc. O arquivo .jsc é inútil sem seu arquivo .js correspondente.
O tradeoff: tamanho de arquivo
Arquivos de bytecode são significantemente maiores que código fonte - tipicamente 2-8x maiores.
Por que bytecode é tão maior?
Instruções de bytecode são verbosas: Uma única linha de JavaScript minificado pode compilar para dezenas de instruções de bytecode. Por exemplo:
const sum = arr.reduce((a, b) => a + b, 0);Compila para bytecode que:
- Carrega a variável
arr - Obtém a propriedade
reduce - Cria a arrow function (que tem seu próprio bytecode)
- Carrega o valor inicial
0 - Configura a call com o número certo de argumentos
- Realiza a call
- Armazena o resultado em
sum
Cada um destes passos é uma instrução de bytecode separada com seus próprios metadados.
Pools de constantes armazenam tudo: Toda string literal número nome de propriedade - tudo é armazenado no pool de constantes. Mesmo que seu código fonte tenha "hello" cem vezes o pool de constantes armazena uma vez mas a tabela de identificadores e referências de constantes adicionam overhead.
Metadados por função: Cada função - até mesmo funções pequenas de uma linha - recebe seus próprios metadados completos:
- Info de alocação de registradores
- Bitmask de recursos de código
- Modo de parse
- Handlers de exceção
- Info de expressão para debugging
Um arquivo com 1.000 funções pequenas tem 1.000 conjuntos de metadados.
Estruturas de dados de profiling: Mesmo que dados de profiling não estejam populados ainda as estruturas para armazenar dados de profiling são alocadas. Isso inclui:
- Slots de value profile (rastreando quais tipos fluem através de cada operação)
- Slots de array profile (rastreando padrões de acesso a array)
- Slots de binary arithmetic profile (rastreando tipos de número em operações matemáticas)
- Slots de unary arithmetic profile
Estes ocupam espaço mesmo quando vazios.
Fluxo de controle pré-computado: Jump targets tabelas switch e limites de exception handler são todos pré-computados e armazenados. Isso torna execução mais rápida mas aumenta o tamanho do arquivo.
Estratégias de mitigação
Compressão: Bytecode comprime extremamente bem com gzip/brotli (compressão de 60-70%). A estrutura repetitiva e metadados comprimem eficientemente.
Minificação primeiro: Usar --minify antes de gerar bytecode ajuda:
- Identificadores menores → tabela de identificadores menor
- Eliminação de código morto → menos bytecode gerado
- Constant folding → menos constantes no pool
O tradeoff: Você está trocando arquivos 2-4x maiores por inicialização 2-4x mais rápida. Para CLIs isso geralmente vale a pena. Para servidores de longa execução onde alguns megabytes de espaço em disco não importam é ainda menos um problema.
Versionamento e portabilidade
Portabilidade entre arquiteturas: ✅
Bytecode é independente de arquitetura. Você pode:
- Build em macOS ARM64, deploy em Linux x64
- Build em Linux x64, deploy em AWS Lambda ARM64
- Build em Windows x64, deploy em macOS ARM64
O bytecode contém instruções abstratas que funcionam em qualquer arquitetura. Otimizações específicas de arquitetura acontecem durante compilação JIT em tempo de execução não no bytecode em cache.
Portabilidade entre versões: ❌
Bytecode não é estável entre versões do Bun. Eis o porquê:
Formato de bytecode muda: O formato de bytecode do JavaScriptCore evolui. Novos opcodes são adicionados antigos são removidos ou alterados estruturas de metadados mudam. Cada versão do JavaScriptCore tem um formato de bytecode diferente.
Validação de versão: A versão do cache no cabeçalho do arquivo .jsc é um hash do framework JavaScriptCore. Quando Bun carrega bytecode:
- Extrai a versão do cache do arquivo
.jsc - Computa a versão atual do JavaScriptCore
- Se não corresponderem o bytecode é silenciosamente rejeitado
- Bun faz fallback para parsing do código fonte
.js
Sua aplicação ainda roda - você apenas perde a otimização de desempenho.
Degradação graciosa: Este design significa que cache de bytecode "falha abertamente" - se algo der errado (incompatibilidade de versão arquivo corrompido arquivo ausente) seu código ainda roda normalmente. Você pode ver inicialização mais lenta mas não verá erros.
Bytecode não vinculado vs vinculado
JavaScriptCore faz uma distinção crucial entre bytecode "não vinculado" e "vinculado". Esta separação é o que torna o cache de bytecode possível:
Bytecode não vinculado (o que é em cache)
O bytecode salvo em arquivos .jsc é bytecode não vinculado. Contém:
- As instruções de bytecode compiladas
- Informações estruturais sobre o código
- Constantes e identificadores
- Informações de fluxo de controle
Mas não contém:
- Ponteiros para objetos de runtime reais
- Código de máquina compilado JIT
- Dados de profiling de execuções anteriores
- Informações de link de call (quais funções chamam quais)
Bytecode não vinculado é imutável e compartilhável. Múltiplas execuções do mesmo código podem todas referenciar o mesmo bytecode não vinculado.
Bytecode vinculado (execução em runtime)
Quando Bun executa bytecode ele "vincula" - criando um wrapper de runtime que adiciona:
- Informações de link de call: Conforme seu código roda o motor aprende quais funções chamam quais e otimiza aqueles call sites.
- Dados de profiling: O motor rastreia quantas vezes cada instrução executa quais tipos de valores fluem pelo código padrões de acesso a array etc.
- Estado de compilação JIT: Referências para versões compiladas baseline JIT ou otimizando JIT (DFG/FTL) de código hot.
- Objetos de runtime: Ponteiros para objetos JavaScript reais prototypes scopes etc.
Esta representação vinculada é criada do zero toda vez que você roda seu código. Isso permite:
- Cache do trabalho caro (parsing e compilação para bytecode não vinculado)
- Ainda coletar dados de profiling em runtime para guiar otimizações
- Ainda aplicar otimizações JIT baseadas em padrões de execução reais
Cache de bytecode move trabalho caro (parsing e compilação para bytecode) de tempo de runtime para tempo de build. Para aplicações que iniciam frequentemente isso pode reduzir pela metade seu tempo de inicialização ao custo de arquivos maiores em disco.
Para CLIs de produção e deployments serverless a combinação de --bytecode --minify --sourcemap oferece o melhor desempenho mantendo a capacidade de debug.