Skip to content

Vaynerov Technologies

Não nos limitamos a desenvolver — conjuramos cada linha de código e cada píxel.

Todos os artigosMergulhos técnicos

Acelerar o Safari móvel: dois erros, um sintoma

A queixa tinha quatro palavras: demora imenso, às vezes nunca carrega. Afinal eram dois erros sem relação nenhuma vestidos com a mesma reclamação — um na forma como as páginas eram entregues, outro no que elas faziam ao telemóvel depois de chegarem. Eis a campanha inteira, incluindo a decisão que revertemos vinte e três dias depois de a tomarmos.

Vaynerov TechnologiesO estúdio
Publicado 15 min de leitura
Nesta página
  1. Todas as rotas de locale eram dinâmicas
  2. Guardar em cache a base de dados, não a página
  3. A mesma alavanca, com vinte e três dias de intervalo
  4. Noventa animações que ninguém estava a ver
  5. Todos os iPhones são dispositivos de gama baixa
  6. LazyMotion, à segunda tentativa
  7. O último ulp

Todos os relatos de desempenho vindos de uma pessoa real chegam comprimidos. O nosso dizia que o site demorava imenso num iPhone e que às vezes nunca chegava a carregar. Essa frase não contém diagnóstico nenhum, e a forma mais rápida de desperdiçar uma semana é tratá-la como um só erro — começar a apagar animações porque a página parece pesada, ou começar a acrescentar cabeçalhos de cache porque a página parece lenta. O trabalho de desempenho no Safari móvel só converge quando se parte o sintoma em dois: quanto tempo o telemóvel espera pelos bytes e o que o telemóvel faz depois de os ter.

Medimos as duas coisas, encontrámos uma causa distinta em cada uma e corrigimo-las em dois commits no mesmo dia. A metade da entrega era um erro estrutural na nossa árvore de rotas de Next.js que tornava dinâmica a renderização de todas as páginas localizadas. A metade do runtime era acumulação — cerca de noventa animações infinitas, um palco WebGL a armar-se em hardware a quem isso nunca devia ter sido pedido, e o núcleo de animação do framer-motion à boleia no bundle inicial da rota da página inicial.

250–950 ms → 3–8 ms
TTFB da página inicial
1.12 MB → 468 KB
HTML da página inicial
7 → 345
rotas prerenderizadas
−91 KB
JS inicial da página inicial

O resultado nas duas frentes, medido a 2026-07-26 nos commits de entrega e de runtime.

Todas as rotas de locale eram dinâmicas

O número da entrega já era feio antes de a rede sequer entrar em cena. O TTFB da página inicial andava nos 250–950 ms em localhost, com dez a vinte consultas ao Supabase sem cache por visualização. Num portátil, em loopback, com a base de dados quente. Fosse o que fosse que o telemóvel estava a passar, era isso mais tudo o que uma ida e volta pela rede móvel acrescenta.

A causa não eram as consultas. Era que nenhuma página tinha autorização para ser estática. O next-intl nunca tinha sido posto em renderização estática e — a parte que realmente contava — o nosso layout de raiz resolvia o idioma ativo a partir do estado do pedido, chamando getLocale(). Uma leitura em tempo de pedido dentro de um layout contamina todas as rotas abaixo dele. O Next.js concluiu, e bem, que nada abaixo desse layout podia ser prerenderizado, por isso todas as rotas de locale eram renderizadas a cada pedido, servidas com no-store e nunca se tornavam armazenáveis em CDN. O leque de chamadas ao CMS corria depois em todas as visualizações, porque não havia nada onde as guardar em cache.

A correção não foi um cabeçalho de cache. Foi apagar o app/layout.tsx.

A API mais recente do Next, next/root-params, existe exatamente para esta forma de problema, e aqui não se aplica: [locale] não é um parâmetro de raiz enquanto houver um layout por cima dele. A única maneira de fazer do segmento de idioma o topo da árvore é não ter nada acima dele — o que significa abdicar por completo do layout de raiz único e partilhado. E foi o que fizemos. Já não existe nenhum app/layout.tsx no repositório. No seu lugar estão quatro layouts de raiz, um por família de rotas, cada um a renderizar um <RootShell> partilhado que é dono da marcação <html> e <body> e de tudo o que é global: [locale], mais (admin), (admin-auth) e pitch, com estes três últimos a fixarem locale="en", porque nada numa consola de administração precisa de uma tabela de traduções. O global-error.tsx mantém o seu próprio <html>, como tem de ser.

Antes: um layout de raiz lê o idioma do estado do pedido e toda a árvore abaixo dele passa a dinâmica. Depois: quatro layouts de raiz partilham um componente em vez de um pai, e a árvore de locales é estática/ISR.

A outra metade da adesão é por página: o setRequestLocale tem de ser chamado em todos os componentes de servidor que rendem uma página localizada, e chamamo-lo agora em 34 ficheiros. Este é um invariante genuinamente mau — é invisível, é silencioso quando violado, e uma página que se esqueça dele não parte, apenas volta discretamente a ser dinâmica e leva o TTFB atrás. Escrevemos a regra nos comentários dos ficheiros de onde uma página nova é mais provável de ser copiada, e escrevemo-la também aqui: páginas novas têm de repetir o setRequestLocale.

Uma rota ficou dinâmica de propósito. O roadmap interativo lê useSearchParams sem uma fronteira de Suspense, o que o prende à renderização dinâmica; é uma superfície noindex de qualquer forma, e forçar a questão teria implicado reestruturar uma página de que ninguém se queixava. Fixá-la explicitamente foi melhor do que deixá-la acidental.

Guardar em cache a base de dados, não a página

A prerenderização corrige o caminho do pedido, mas o nosso conteúdo é editável: os textos das secções, os metadados das páginas e o conteúdo das linhas saem todos do Supabase através de uma pequena camada de CMS. Uma matriz de compilação de seis idiomas que vá à base de dados uma vez por consulta por página trocaria um pedido lento por uma compilação lenta. Por isso os três caminhos de leitura foram para trás de unstable_cachegetSectionRow sob ["cms-section"], getRowsForLocale sob ["cms-rows"], getPageMetaRow sob ["cms-page-meta"], todos com { revalidate: 300, tags: ["cms-content"] }. A renderização de uma página, e a compilação inteira dos seis idiomas, vai agora ao Supabase uma vez por consulta distinta a cada cinco minutos, em vez de uma vez por pedido.

Um pormenor dessa frase é estrutural e fácil de perder numa refatoração: o CMS usa um cliente Supabase público e sem cookies. Chamar cookies() dentro de unstable_cache lança exceção no Next 15+, por isso um cliente que leia a sessão não pode ser guardado em cache de todo. Sabemo-lo porque o mesmo erro já tinha matado em silêncio os nossos overrides de tradução na base de dados uns meses antes, atrás de um catch vazio. Os editores também não esperam os cinco minutos — as ações de escrita de administração juntam revalidatePath a revalidateTag("cms-content"), e os overrides de i18n têm a sua própria etiqueta "i18n-overrides".

Os números da entrega mexeram-se em bloco. A compilação passou de 7 rotas prerenderizadas para 345, ao longo de 497 páginas estáticas. A página inicial é servida com x-nextjs-cache: HIT e um TTFB na casa dos 3–8 ms — números de CDN, porque agora é um artefacto de CDN. O próprio HTML caiu de 1,12 MB para 468 KB, embora não inteiramente por causa da prerenderização, o que nos traz à decisão que revertemos.

A mesma alavanca, com vinte e três dias de intervalo

A 3 de julho ligámos o inlineCss do Next. O raciocínio era sólido e as provas eram reais: a nossa folha de estilos tinha 38,6 KB, era o último pedido bloqueante da página e custava cerca de 530 ms de FCP em móvel. Colocá-la em linha retirou uma ida e volta do caminho crítico. Apertámos a browserslist na mesma alteração — Chrome, Edge e Firefox 111+, Safari e iOS 16.4+ — porque é uma base moderna que faz de uma folha de estilos pequena uma folha pequena.

A 26 de julho desligámo-lo. Duas coisas tinham mudado. A folha de estilos crescera para cerca de 320 KB em bruto (uns 40 KB comprimidos); e o CSS em linha viaja dentro do HTML de todas as navegações, sem forma de o navegador o guardar em cache entre páginas. Quando todas as páginas eram uma renderização dinâmica, isso era uma troca justa — o HTML não era guardável em cache de qualquer forma, por isso o CSS não perdia nada em juntar-se a ele. Assim que as rotas passaram a prerenderizadas e guardadas em CDN, a aritmética inverteu-se: uma folha de estilos guardável em cache, obtida uma vez, ganha a 320 KB reentregues dentro de cada documento.

A mesma alavanca, a conclusão oposta, com vinte e três dias de intervalo — e ambas as decisões estavam certas no seu contexto.

Guardamos este par sempre que alguém nos cita uma regra de desempenho. O inlineCss não é bom nem mau. É uma aposta na razão entre o tamanho da sua folha de estilos e a capacidade de o seu HTML ser guardado em cache, e fomos nós próprios que mudámos essa razão.

Noventa animações que ninguém estava a ver

A metade do runtime não foi um erro; foram três meses de erros pequenos. Animações CSS infinitas, camadas de mistura à largura da página, um palco WebGL e o núcleo do framer-motion carregado à cabeça no bundle inicial — cada coisa defensável quando entrou, e coletivamente um derretedor de telemóveis. A medição mais clara: quando se chega, a rolar, à chamada à ação final da página inicial, há cerca de noventa animações infinitas vivas em simultâneo. Ao compositor não lhe interessa que oitenta delas estejam muito para lá do ecrã.

A barreira é deliberadamente burra. O SectionViewportGate é um componente de cliente que observa todos os #main-content > section com uma margem de raiz de 160 px e alterna um atributo data-offscreen; o CSS faz o resto com animation-play-state. As trocas de atributo acontecem fora do React de propósito — encaminhar noventa callbacks de observador por atualizações de estado teria custado mais do que as animações que estávamos a tentar pausar.

Ao lado disso, pegámos no content-visibility: auto, que deixa o navegador saltar layout e pintura das secções até onde ainda não se rolou. Essa alteração saiu com um erro que vale a pena contar.

/* nth-of-type, not nth-child. Three JSON-LD <script> children
   precede the sections inside <main>. */
section:nth-of-type(n + 3) {
  content-visibility: auto;
  contain-intrinsic-size: auto 800px;
}

Uma pseudoclasse, e uma classe inteira de CLS muito confuso. O herói nunca pode corresponder a esta regra.

Escrito como nth-child(n+3), o seletor conta todos os filhos de <main> — e há três etiquetas <script> de JSON-LD à frente. O desfasamento saltou três posições e a regra caiu em todas as secções, herói incluído. O content-visibility implica contain: size, por isso o herói de 100svh fez o layout primeiro à altura do seu marcador de 800 px e corrigiu-se um instante depois: um refluxo no carregamento e um deslocamento de layout logo no primeiro ecrã, produzidos por uma alteração cujo objetivo inteiro era tornar o primeiro ecrã mais barato. O nth-of-type conta apenas elementos <section>. Uma palavra, e o herói volta a estar fora do âmbito.

Todos os iPhones são dispositivos de gama baixa

As nossas superfícies WebGL vivem atrás de um detetor de nível de qualidade. O detectTier lê quatro sinais — pointer: coarse, hardwareConcurrency ?? 4, deviceMemory ?? 4 e a menor dimensão do ecrã. Um dispositivo é um telemóvel se tiver ponteiro grosseiro e a dimensão mínima abaixo de 700 px, e um telemóvel só é promovido a médio com pelo menos seis núcleos e pelo menos seis gigabytes de memória reportada.

NívelComo um dispositivo lá chegaLimite de DPR
lowQualquer telemóvel que falhe a promoção de 6 núcleos / 6 GB — ou seja, todos os iPhones1,25
mediumComputadores de secretária por omissão, telemóveis promovidos e todas as renderizações no servidor1,5
highPonteiro fino com os núcleos e a memória que o sustentem2

A tabela de níveis, e a razão pela qual o valor por omissão no SSR é medium: o servidor não pode saber, e médio é o palpite honesto.

O Safari não expõe deviceMemory de todo. O valor por omissão ?? 4 falha por isso o requisito de ≥6 incondicionalmente, e todos os iPhones caem no nível baixo. Passámos uma hora a tratar isso como um erro de deteção antes de concluirmos que era a resposta certa alcançada por acaso: o Safari do iOS tem o menor limite de contextos WebGL de todos os navegadores que suportamos e é o mais disposto a matar um separador sob pressão de memória. Por isso o palco agora retorna antes de montar quando tier === "low", o que significa que o chunk do palco nem sequer chega a ser transferido. O contexto WebGL mais barato é aquele que se recusa a criar.

O resto da dieta de runtime é uma lista de pequenas amputações, cada uma a remover trabalho de que os dispositivos de toque nunca poderiam beneficiar:

  • O campo de código do herói é estático no toque, sem ciclo de fotogramas nenhum. Não há cursor para perseguir, e a lente errante custava um redesenho de tela à largura do ecrã a 60 fps logo no primeiro ecrã do telemóvel. O seu caminho de redimensionamento salta também a reconstrução de ~2600 chamadas a fillText, porque no iOS o colapso da barra de endereço conta como um redimensionamento.
  • A sobreposição da lente de código está restringida a pointer: fine. É uma tela fixa em mix-blend-mode — taxava todos os fotogramas de rolagem nos telemóveis para produzir um efeito que o toque não consegue sequer despoletar.
  • As esferas dos trabalhos em destaque trocaram filter: blur(28px) por gradientes radiais de três paragens já cozidos. Como animam a escala, a gaussiana voltava a correr a cada fotograma; um gradiente com o mesmo aspeto não custa nada a reescalar. O will-change está agora limitado a ponteiros capazes de hover, e o toque achata o preserve-3d.
  • Os vídeos foram recodificados para 960×540, o que os levou de 11,2 MB a 5,1 MB. Foram renomeados para *-2.mp4 para honrar o nosso contrato de nomes com cache imutável — os recursos seguem com max-age=31536000, immutable, por isso um ficheiro alterado precisa de um nome alterado, e em troca as visitas repetidas poupam cerca de 100 MB.
Entrega, antes e depois. As barras de TTFB são a medição em localhost nos dois casos — os números do telemóvel eram piores dos dois lados da alteração.

LazyMotion, à segunda tentativa

O item mais longo da campanha começou com um relatório de bundle a acusar 63 KB de JavaScript não utilizado. Fomos ver e descobrimos que o chunk era o próprio react-dom, que não é coisa que se apare. A gordura a sério era o framer-motion: importar motion em qualquer sítio traz o núcleo de animação à cabeça, e a nossa página inicial importava-o na maioria das secções.

A tentativa de 3 de julho embrulhou cada secção no seu próprio LazyMotion com strict ativado, levando a transferência da página inicial de 284 KB a 269 KB. É o modo estrito que torna a poupança real — proíbe os componentes motion.* simples, por isso uma única importação perdida não consegue arrastar o núcleo de volta sem se dar por isso. E tem dentes: tivemos de matar o animate() imperativo do framer dentro do nosso hook de números a contar e reescrevê-lo como um pequeno easeOutQuart sobre requestAnimationFrame, porque essa única importação chegava para pôr o núcleo de animação outra vez no grafo crítico do LCP. Uma variável de ciclo chamada m no componente da órbita de gravitas teve de ser renomeada, porque ensombrava o namespace m do framer.

Depois foi revertido. A reversão é limpa, completa, com um corpo de commit gerado automaticamente e sem razão declarada, o que significa que a coisa mais honesta que podemos dizer sobre ela é que não sabemos o que partiu. Temos opiniões; não temos provas. Essa lacuna é uma lição sobre higiene de commits e é a razão pela qual o regresso teve outra forma.

// One shared provider. The feature bundle is loaded asynchronously,
// so it streams in after first paint instead of blocking it.
const loadDomAnimation = () =>
  import("framer-motion").then((m) => m.domAnimation);

<LazyMotion features={loadDomAnimation} strict>

A forma que voltou: um único MotionProvider com carregamento assíncrono das funcionalidades, e o modo estrito a manter todos os consumidores honestos.

A versão de 26 de julho substituiu os invólucros por secção por um único MotionProvider partilhado, e só entrou depois de todos os consumidores da rota inicial terem sido auditados à mão. São três: a montra dos jogos, a calha do Lab e o invólucro de inclinação dos cartões. A regra da casa está agora escrita — as secções da página inicial usam m.* sob o provider, nunca motion.* — e o modo estrito impõe-na em tempo de execução em vez de em revisão de código.

Vale a pena nomear uma armadilha, porque é invisível em revisão. O CardTilt mantém um tipo de elemento constante: condiciona o seu comportamento ao nível do estilo e nunca troca um div por um m.div. Trocar o tipo de elemento no cliente voltaria a montar toda a subárvore depois da hidratação — o que, para um cartão que contém um palco de tela, significa deitar abaixo e reconstruir precisamente aquilo que se queria tornar mais barato. Condicionar por estilos é mais feio e está correto.

O JavaScript inicial da página inicial ficou nos 1365 KB em bruto, abaixo dos 1456 KB, com zero chunks a transportar o núcleo de movimento.

O último ulp

O artefacto mais estranho da campanha não tinha nada que ver com velocidade. Um dos nossos cartazes de jogo — o do riftline — calcula as coordenadas do SVG por trigonometria, e o React 19 compara os atributos renderizados no servidor com os renderizados no cliente durante a hidratação. A libm do Node e a libm do Chromium discordam na última unidade da última casa. As duas strings diferem num dígito que nenhum humano alguma vez verá, o React repara, e temos um aviso de hidratação numa imagem estática. A correção é quantizar as coordenadas antes de se tornarem atributos, que é outra forma de dizer: se duas implementações têm de concordar num número, não lhes peça que concordem nele todo.

Ambos os commits saíram com o tsc, o eslint e a compilação de produção a verde, e com os 27 testes de ponta a ponta já existentes a passar. Essa última verificação conta mais do que parece: quase todas as alterações aqui removem trabalho, e remover trabalho é a forma mais fácil de remover uma funcionalidade sem querer.

O que diríamos a quem começasse a mesma campanha é que a separação é o método todo. Os erros de entrega e os erros de runtime produzem queixas idênticas e quase não partilham correções; a metade da entrega foi aqui uma decisão estrutural sobre onde vive o idioma na árvore de rotas, e a metade do runtime foram noventa decisões pequenas. Nenhuma delas se encontrava a olhar para a outra.

O invariante com que vivemos agora é o que menos nos agrada. O setRequestLocale tem de aparecer em todas as páginas localizadas, e nada falha quando não aparece — a página deixa simplesmente de ser estática e todo o ganho de entrega escapa-se em silêncio, uma rota de cada vez. Se quiser ver a forma do site que estes números pagam, o Lab é a coisa mais pesada que servimos e o melhor teste para saber se a dieta se aguentou. A reconstrução da página inicial explica porque há tanta coisa para renderizar, e seis línguas num só código-base explica porque é que o segmento de idioma estava onde estava.