OTA Updates no React Native: o que muda, o que não muda, e por quê

OTA Updates no React Native: o que muda, o que não muda, e por quê

Você encontra um bug crítico em produção, corrige uma linha de JavaScript e, tecnicamente, o problema está resolvido:

- const total = price + quantity; + const total = price * quantity;

Mas seus usuários ainda não têm a correção. No fluxo tradicional seria necessário gerar o app de novo, submeter às lojas, aguardar a revisão e torcer para que as pessoas atualizem. Muito processo para um operador trocado.

É nesse cenário que entram os OTA Updates, ou Over-the-Air Updates.

Este artigo não é um tutorial de deploy. A pergunta que ele quer responder é outra:

Como um aplicativo já instalado no celular consegue executar um JavaScript diferente daquele que foi publicado na App Store?

Usaremos o Bitrise CodePush como estudo de caso principal, com o EAS Update da Expo como contraponto.

fluxo tradicional para atualizar apps passando builds por lojas

Parte 1 — O conceito

OTA é o conceito, CodePush é uma implementação

É comum ouvir "vamos implementar CodePush" e "vamos implementar OTA" como sinônimos. Não são.

OTA é a ideia de distribuir atualizações direto para um app já instalado, sem passar um novo .ipa, .aab ou .apk pela loja. CodePush e EAS Update são duas implementações dessa ideia — e existem outras, incluindo servidores próprios, já que o protocolo do expo-updates pode ser implementado por qualquer serviço.

abordagens OTA

Vale um pouco de história, porque ela explica o estado atual do ecossistema. O CodePush nasceu na Microsoft, dentro do Visual Studio App Center. A Microsoft aposentou o App Center, incluindo o CodePush hospedado, em 31 de março de 2025. O protocolo e o SDK sobreviveram, e hoje a Bitrise opera um serviço hospedado.

Segundo a documentação da Bitrise, o produto tem duas partes: o CodePush dentro do Bitrise Release Management, onde você publica, faz rollout e gerencia updates; e o SDK que fica dentro do app, responsável por lidar com os updates disponíveis. O SDK suporta a New Architecture do React Native e projetos Expo.

Parte 2 — Por que o React Native permite isso

As duas camadas

Para entender OTA é preciso abandonar temporariamente a ideia de que um app React Native é uma coisa única. Ele tem duas camadas.

divisões das camadas de um código React Native

A separação não é uma invenção didática. A própria Expo usa exatamente esse enquadramento na documentação do EAS Update: builds podem ser pensados como duas camadas, uma nativa embutida no binário e uma camada de update, intercambiável com outros updates compatíveis. É essa separação que permite entregar correções para builds já instalados, desde que o update consiga rodar sobre a camada nativa do build.

Toda a tecnologia sai daí. O resto do artigo é consequência.

O que o Metro faz com seu JavaScript

Durante o desenvolvimento você tem centenas de arquivos em src/. O dispositivo não executa essa estrutura. O Metro transforma tudo em um bundle.

pipeline Metro

Esse bundle é embarcado no binário distribuído pela loja. É ele que o runtime carrega quando o app abre.

Hermes, e por que isso importa mais do que parece

JavaScript precisa de um engine. Em apps React Native modernos esse engine é o Hermes, padrão desde a versão 0.70 e o único suportado na New Architecture.

Aqui está o detalhe que a maioria dos artigos sobre OTA ignora: o Hermes não interpreta JavaScript em produção. Ele executa bytecode pré-compilado em tempo de build. É isso que elimina o custo de parsing no cold start.

A consequência é direta e cara, e a documentação da Expo é explícita sobre ela: o formato do bytecode do Hermes pode mudar entre versões, e um update produzido para uma versão específica do Hermes não vai rodar em outra. Desde o Expo SDK 46 o Hermes vem embutido no React Native, então atualizar a versão do React Native deve ser tratado como atualizar qualquer outro módulo nativo — se você não acompanhar isso no versionamento, o app pode crashar no launch, porque o update será carregado por um binário existente cujo Hermes é incompatível com o novo bytecode.

Ou seja: o pacote que você publica via OTA não é "só JavaScript". É um artefato compilado, amarrado ao binário que vai executá-lo. Guarde isso — vamos voltar ao ponto na seção de compatibilidade.

O truque: uma linha que decide qual bundle carregar

Agora a pergunta central: de onde vem o bundle entregue ao runtime?

Sem OTA, vem do binário. Com OTA, existe um código nativo capaz de escolher outro arquivo. E o mecanismo é literalmente uma substituição de uma linha na inicialização nativa.

No iOS, a configuração do CodePush troca a resolução do bundle em builds de release:

#else - Bundle.main.url(forResource: "main", withExtension: "jsbundle") + CodePush.bundleURL() #endif

No Android, o equivalente é apontar o jsBundleFilePath para CodePush.getJSBundleFile(). A própria documentação da Bitrise descreve o comportamento em um comentário no código: resolver o caminho do bundle no startup, usando o update OTA se houver um disponível e caindo para o JS embarcado caso contrário.

É isso. Todo o resto — servidor, CDN, rollout, assinatura — é infraestrutura em volta dessa decisão.

decisāo se existe bundle code push válido para update

O app nativo não foi substituído. Mudou apenas qual código o runtime vai carregar.

Note também que a troca vale só para release. O bloco #if DEBUG mantém o Metro packager em builds de debug, para que live reload e dev tools continuem funcionando.

Parte 3 — Os limites

O que não pode ser atualizado

A mesma divisão que dá poder ao OTA define sua limitação. Se o JavaScript novo chama uma API nativa que não existe no binário instalado, o resultado é erro ou crash.

A Expo descreve exatamente esse cenário: imagine um build com runtime version 1.0.0 já publicado nas lojas. Depois você desenvolve um update que depende de uma biblioteca nativa recém-instalada, como a expo-camera, e não altera a runtime version. Os builds com runtime 1.0.0 vão considerar o update compatível e tentar carregá-lo. Como o update chama código que não existe dentro do build, o expo-updates pode detectar o erro e tentar voltar para o update anterior que funcionava.

Passa por OTA: JavaScript, componentes React, regras de negócio, estilos e assets.

Exige novo build: adicionar ou remover módulos nativos, mexer em Swift, Objective-C, Kotlin ou Java, alterar capabilities e permissões, e — pelo que vimos acima — subir a versão do React Native ou do Hermes.

Um cuidado com a leitura do diagrama das duas camadas: quando ele lista Camera, Maps ou Notifications como imutáveis, isso vale para o SDK nativo dessas bibliotecas. A tela que chama a câmera, o tratamento de permissão negada, o componente que renderiza o mapa — tudo isso é JavaScript e atualiza normalmente.

camadas do React Native

Há ainda uma armadilha específica da New Architecture. As specs de TurboModules e de componentes Fabric são escritas em TypeScript, então parecem código atualizável. Mas o Codegen as transforma em código nativo durante o build. Criar um TurboModule novo ou mudar a assinatura de um existente exige rebuild, mesmo que você só tenha editado um arquivo .ts.

Compatibilidade: o problema real

Nem todos os usuários atualizam. Em produção você tem várias versões de binário rodando ao mesmo tempo, e um update publicado precisa saber para quais delas é seguro.

As duas plataformas resolvem isso de formas diferentes.

Bitrise CodePush usa target versions. Você informa a versão do app ao subir o pacote, e só quem estiver rodando aquela versão recebe. Expressões de intervalo são aceitas: 1.2.3 atinge só aquela versão de loja; * atinge qualquer dispositivo configurado para consumir updates; 1.2.x cobre qualquer patch da minor 2; 1.2.3 - 1.2.7 cobre o intervalo com os dois extremos inclusos; >=1.2.3 <1.2.7 exclui o limite superior; e os operadores ~1.2.3 e ^1.2.3 seguem a convenção do semver.

EAS Update usa runtimeVersion, que é mais explícito sobre o que está sendo versionado. A runtime version descreve a interface entre o JS e o nativo definida pela camada nativa que roda a camada de update. Sempre que você mudar código nativo de forma a alterar essa interface, precisa mudar a runtime version.

E a regra de compatibilidade é rígida: a plataforma do build e a do update precisam bater exatamente, e a runtime version do build e a do update também precisam bater exatamente. Não há intervalo.

O runtimeVersion aceita políticas que derivam o valor automaticamente. O padrão configurado pelo eas update:configure é a política appVersion, que mantém a runtime version sempre igual à versão do app — a versão nativa que aparece na loja, sem o build number. Mas essa política tem um furo conhecido, e a documentação é honesta sobre ele: a política appVersion incrementa a runtime version junto com a versão do app, mas se você esquecer de subir a versão ao mexer no runtime nativo, vai acabar com um mismatch. Se quiser tornar updates incompatíveis extremamente improváveis, ao custo de precisar gerar builds com mais frequência, existe a política fingerprint, que incrementa a runtime version sempre que qualquer coisa capaz de afetar o runtime nativo muda.

A diferença de filosofia entre as duas é interessante. Target versions amarra o update à versão comercial do app; runtimeVersion amarra ao contrato técnico entre JS e nativo. A segunda abordagem é mais precisa, mas exige disciplina para manter o campo correto.

O que as lojas dizem

Ser tecnicamente possível não significa ser permitido.

A diretriz 2.5.2 da App Review diz que apps devem ser autocontidos em seus bundles e não podem baixar, instalar ou executar código que introduza ou altere funcionalidades do app. Lida isoladamente, ela pareceria proibir OTA por completo.

A brecha está no contrato de desenvolvedor. A seção 3.3.1(B) do Apple Developer Program License Agreement permite que código interpretado seja baixado por um aplicativo, desde que não altere o propósito primário do app fornecendo funcionalidades inconsistentes com o que foi submetido.

É por isso que o React Native, que baixa e executa JavaScript, convive com a App Store — e por que apps de "vibe coding" que geram funcionalidade arbitrária em runtime têm sido removidos sob a mesma diretriz.

O princípio prático:

OTA é para distribuir correções e mudanças compatíveis com o app que foi aprovado. Não é mecanismo para transformar silenciosamente o app em outro produto.

Políticas mudam. Isso deveria fazer parte da revisão do processo de release do time, não só da implementação técnica.

Parte 4 — Operando com segurança

Deployments e rollout gradual

Enviar um update direto para todos os usuários é arriscado. Por isso os dois sistemas trabalham com ambientes separados.

No CodePush são deployments, cada um com sua própria deployment key. O padrão é ter Staging e Production, testar no primeiro e promover para o segundo.

Um detalhe de segurança que costuma gerar confusão, e que a documentação avisa: as deployment keys terminam como strings simples nos builds finais. Elas não são segredos, mas são únicas do seu workspace e da sua configuração de deployment. Não adianta tentar escondê-las; a proteção real vem da assinatura, mais adiante.

No EAS a estrutura é diferente e vale entender porque é mais flexível. Um update é publicado em uma branch, que é um objeto no servidor contendo uma lista de updates, onde o mais recente é o ativo — a analogia com branches do Git é direta. Cada build carrega um channel, e um channel pode ser ligado a qualquer branch; por padrão, ao mesmo nome.

Essa indireção entre channel e branch é o que permite promover uma versão inteira sem republicar nada: você reaponta o channel de produção para outra branch e os apps passam a receber aqueles updates.

Nos dois casos vale começar pequeno. No CodePush, a porcentagem de rollout é definida ao publicar o update e pode ser aumentada depois; o padrão é 100%. A recomendação óbvia é não deixar no padrão para releases arriscados.

Ao subir o pacote no CodePush você também define se ele está Enabled, se é Mandatory (que força o usuário a atualizar imediatamente) e qual o alcance. Os arquivos aceitos são .bundle, .jsbundle ou .zip, com tamanho máximo de 50 MB.

Quando o update é aplicado

O comportamento padrão do SDK é silencioso. O app baixa updates disponíveis automaticamente e os aplica na próxima vez que reiniciar, sem que o usuário veja qualquer diálogo.

Isso explica a dúvida mais comum de quem começa: você publica o update, abre o app e nada muda. É o esperado. Naquela abertura o runtime já tinha carregado o bundle antigo; o download aconteceu em background. A troca aparece na abertura seguinte.

Dá para mudar isso. O SDK expõe modos de instalação: aplicar quando o app volta do background, ou imediatamente, com ou sem diálogo pedindo permissão ao usuário. Quando um update tem a flag mandatory, o usuário é notificado mas não tem a opção de ignorar.

fluxo de boot do app

Rollback: o mecanismo que impede o desastre

Aqui está o problema mais interessante de qualquer sistema OTA.

Imagine que a v3 impede o app de iniciar. Publicar a v4 pode não resolver — o app talvez nem fique aberto tempo suficiente para baixá-la. Sem uma rede de proteção no cliente, você acabou de brickar sua base instalada, e a única saída é uma submissão emergencial à loja.

O CodePush resolve isso com uma confirmação explícita. Um update recém-instalado fica em estado provisório até o app declarar que subiu bem. Nas palavras da referência da API, é obrigatório chamar essa função em algum ponto do código do bundle atualizado. Caso contrário, na próxima vez que o app reiniciar, o runtime do CodePush vai assumir que o update instalado falhou e voltar para a versão anterior.

Na prática você raramente chama isso à mão, porque se você usa a função sync e faz a verificação no início do app, não precisa chamar manualmente — o sync faz isso por você, partindo do princípio de que o ponto onde o sync é chamado é uma boa aproximação de um startup bem-sucedido.

Duas consequências que valem para o desenho do seu app:

Se você chamar sync cedo demais, no primeiro render, está declarando sucesso antes de saber se o app funciona. O rollback automático perde a utilidade. Chamar depois de verificar que a navegação subiu e as chamadas essenciais responderam é mais seguro.

E se você gerencia updates manualmente, sem sync, esquecer a confirmação faz todo update ser revertido no reinício seguinte — o app fica preso na versão embarcada e o sintoma parece um bug do servidor.

Existe também rollback do lado do servidor, que impede novos usuários de receberem uma release ruim depois que ela foi identificada. Os dois mecanismos são complementares: o do cliente salva quem já instalou, o do servidor estanca a propagação.

O EAS Update tem seu próprio mecanismo de error recovery, com a mesma intenção.

Assinatura: o modelo de confiança

Estamos permitindo que um servidor remoto envie código para o app executar. Isso precisa de mais garantia do que "veio da URL certa".

O code signing do CodePush funciona em três estágios. Primeiro, você gera um par de chaves RSA: a privada assina os bundles, a pública é embutida no app. Segundo, ao publicar um update, o CLI assina o bundle com a chave privada, criando um JWT que contém o hash do bundle. Terceiro, o app — com a chave pública embutida — verifica a assinatura do JWT antes de aplicar o update; se a verificação falhar, o update é rejeitado.

O recurso exige a versão 5.1.0 ou superior do SDK.

Isso muda a pergunta que o app faz. Em vez de "esse arquivo veio do servidor certo?", ele pergunta "esse update foi assinado por quem detém nossa chave privada?". Como as deployment keys não são segredos, a assinatura é a única defesa real contra um pacote adulterado.

substituição de código por code push

O que o OTA custa

Quase todo material sobre OTA é advocacia. Vale ser honesto sobre o preço.

Complexidade de suporte. Você deixa de ter uma versão do app e passa a ter duas dimensões: a versão nativa e a versão do bundle. "Qual código esse usuário está rodando?" vira uma pergunta genuinamente difícil, e seu time de suporte precisa conseguir respondê-la.

Um SDK a mais. Código nativo adicional no binário, que faz uma requisição durante o ciclo de vida do app e escreve no sistema de arquivos.

Risco de política. As lojas podem revisar a interpretação da regra a qualquer momento, e sua estratégia de release depende dela.

Disciplina de versionamento. O erro clássico é atualizar uma dependência nativa e esquecer de mudar a runtime version ou o target. O sistema não vai te avisar — os usuários vão.

Custo real. O modelo da Bitrise cobra por usuários ativos mensais, transferência de dados e armazenamento, não por número de updates.

Parte 5 — Comparação e modelo mental

CodePush e EAS Update lado a lado

Ambos resolvem o mesmo problema: entregar uma nova camada de update para um runtime nativo já instalado. O que muda é o ecossistema e as abstrações.

| | Bitrise CodePush | EAS Update | | --- | --- | --- | | Cliente | `@bitrise/code-push-sdk` | `expo-updates` | | Backend | Bitrise Release Management | EAS | | Compatibilidade | Target versions (intervalos) | `runtimeVersion` (igualdade exata) | | Ambientes | Deployments | Channels + branches | | Rollout gradual | Sim | Sim | | Code signing | Sim (RSA + JWT) | Sim | | React Native bare / Expo | Sim / Sim | Sim / Sim |

Vale entender uma diferença de implementação no download. O expo-updates baixa em duas fases: primeiro o manifest mais recente, que descreve o update e lista os assets necessários; depois, apenas os assets que ainda não foram baixados em updates anteriores. Se o manifest e todos os assets chegarem dentro do fallbackToCacheTimeout, o update roda imediatamente no launch; caso contrário, o download continua em background e ele roda na próxima abertura.

Essa reutilização de assets é o motivo pelo qual manter updates pequenos importa: só o que mudou trafega.

E a cascata de fallback do Expo é uma boa síntese de como esses sistemas pensam: se não encontrar um update mais novo, a biblioteca roda o update mais recente já baixado, caindo para o update embarcado no build se nenhum tiver sido baixado. O bundle embarcado nunca deixa de ser necessário — ele é a base segura para quem instalou o app e abriu sem internet.

O modelo mental

Se você levar uma coisa só deste artigo, que seja esta.

Um app instalado tem duas camadas. A nativa vem do binário e só muda pela loja. A de update é intercambiável e pode chegar pelo ar, desde que continue compatível com a nativa que vai executá-la.

execução do código

Todo o resto existe porque essa compatibilidade não é automática. Targeting, runtime versions, deployments, rollouts, assinatura, rollback e monitoramento não são funcionalidades extras de uma plataforma — são respostas para problemas que aparecem naturalmente quando você deixa um app instalado executar código que evolui separado do seu binário.

Uma definição, então:

OTA é a capacidade de evoluir a camada JavaScript de uma aplicação sem substituir sua camada nativa — desde que as duas continuem falando a mesma língua.

E boa parte do trabalho de engenharia está em garantir que continuem.

Fontes