Cosa pequeña. Me llevó muchísimo tiempo. El favicon, el pequeño icono en la pestaña del navegador, es el recurso más diminuto de todo este sitio y me costó más pelea que el proxy inverso.
lo que se supone que es
El icono de pestaña aquí es un monograma BMK diminuto con un glifo de montaña metido dentro, a juego con el logotipo de la cabecera. No lo dibujé a mano como imagen ráster, lo construí como una tile HTML (static/_favicon-tile.html) usando la fuente Literata vendorizada real y los colores reales del sitio, y luego lo rendericé a PNG con Playwright para que el glifo de verdad use la misma tipografía que el resto del sitio en vez de alguna fuente de sistema de respaldo que un elemento <text> de SVG habría sustituido en silencio. Se generan dos tamaños, 32px para la pestaña y 512px para todo lo que quiera un icono más grande (accesos directos de pantalla de inicio, ese tipo de cosas), más un apple-touch-icon a 180px.
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=2" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png?v=2" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png?v=2" />Fíjate en el ?v=2 al final de cada uno. Eso no estaba ahí originalmente. Esa es la cicatriz de toda esta historia.
ronda uno: sencillamente no se actualizaba
Rediseñé el favicon a mitad del rediseño general, regeneré los PNG, desplegué, recargué la página. Icono antiguo. Todavía la vieja forma de montaña en la pestaña. Supuse que había metido la pata en el build, revisé el archivo en disco, y los bytes nuevos estaban ahí mismo, píxeles correctos, archivo correcto, ruta correcta. El navegador simplemente no lo estaba pidiendo de nuevo.
Resulta que los favicons son el único tipo de recurso donde los navegadores cachean con algo cercano a la devoción religiosa, y en su mayoría ignoran las cabeceras normales de cache-control que usarías para romper una caché en cualquier otra cosa. Chrome en particular ha cacheado históricamente el favicon de un sitio durante mucho tiempo con independencia de lo que diga el servidor, a veces necesitando borrar el historial y la caché por completo, a veces necesitando un cambio de URL real, no solo un byte fresco en la misma URL. Una simple recarga no lo iba a tocar. Tampoco recargar con la caché desactivada en las devtools, que fue lo segundo que probé y que normalmente arregla exactamente esta clase de problema.
ronda dos: culpar a la capa equivocada
Antes de averiguar que era el navegador, pasé un rato sospechando del contenedor en su lugar, porque este stack corre detrás de Docker y ya me han quemado antes las capas de build obsoletas (un bug real distinto anterior en este proyecto involucró que node_modules se destrozara en un build de Docker porque todavía no había escrito un .dockerignore). Así que fui por ese camino primero: reconstruí la imagen del frontend con --no-cache, confirmé que los bytes nuevos del PNG estaban sentados en frontend/build/client/favicon-32.png dentro del contenedor en ejecución, hice curl a la URL directamente contra el propio puerto del contenedor para saltarme Caddy por completo. Bytes frescos, cada vez, directo de la fuente. El servidor estaba haciendo su trabajo correctamente todo el tiempo. Nunca fue el contenedor. Estaba puramente sentado en la caché de favicon de mi propio navegador, varias capas lejos de cualquier cosa que un despliegue pudiera tocar.
Esa es la parte que de verdad costó el tiempo: descartar mi propia infraestructura antes de aceptar que el bug vivía en algún sitio donde no tenía ningún control.
el arreglo
No puedes decirle a la caché de favicon de un navegador que se invalide. Lo que sí puedes hacer es dejar de pedir la misma URL. Todo lo que probé antes de averiguarlo mantenía la misma URL:
<!-- before: same URL every time, browser never re-asks for it -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />Una query string al final del href lo convierte en un recurso distinto en lo que a caché se refiere, aunque resuelva exactamente al mismo archivo en disco:
<!-- after -->
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png?v=2" />?v=2 es todo el arreglo. La próxima vez que cambie el arte del favicon de verdad, eso se convierte en ?v=3, y así sucesivamente. Es el mismo truco que romper la caché de un bundle de CSS o JS con un hash de contenido, solo que hecho a mano aquí ya que un favicon es una edición tan rara que un pipeline entero de herramientas de build para eso se sentía excesivo. Un número, incrementado a mano, cada vez que los píxeles cambian de verdad.
lo que le diría a mi yo del pasado
Comprueba la explicación fácil, tonta y vergonzosa antes que la interesante. "El navegador simplemente está siendo terco con un PNG pequeño" es una respuesta aburrida y yo quería que el bug fuera algo más técnicamente satisfactorio, alguna sutileza de caché de capas de Docker para la que pudiera escribir un arreglo más inteligente. No lo era. Era una query string de cinco caracteres que podría haber añadido en los primeros diez minutos si hubiera creído la explicación aburrida en vez de perseguir al contenedor durante media hora primero.
lo que lo empeoró antes de que mejorara
Parte de por qué dudé de la teoría de la caché del navegador durante tanto tiempo es que primero la probé en una ventana de incógnito, pensando que eso descartaría la caché del navegador por completo, perfil fresco, sin historial, seguro que tampoco caché de favicon. Equivocado, el icono viejo apareció ahí también la primera vez, lo que se sintió como evidencia sólida de que no era un problema de caché en absoluto. Lo que no me di cuenta hasta más tarde es que la caché de favicon de Chrome no está estrictamente ligada a la caché normal o al historial del perfil de navegación de la misma manera que los recursos de página, es más parecida a un almacén separado y de vida más larga, indexado más por la propia URL, y una ventana privada tampoco arranca de forma fiable esa vacía. Así que mi prueba de control en realidad no estaba controlando lo que yo pensaba que estaba controlando, y me mandó más lejos por el camino de culpar al contenedor de lo que la evidencia debería haber permitido.
También hay una razón más pequeña y aburrida por la que el lado del build sí merecía un primer vistazo: los mismos PNG del favicon existen copiados en varios lugares de salida de build distintos a la vez, frontend/build/client/, la salida de desarrollo de SvelteKit bajo .svelte-kit/output/client/, y el directorio fuente static/ del que todos se originan. Tres copias de los mismos dos archivos en un build normal es exactamente el tipo de cosa que se queda obsoleta en un sitio y no en otro si se salta un paso del build, así que descartar "se sirvió la copia equivocada" no era paranoia, simplemente no fue ahí donde vivía el bug real esta vez.
También pensé en simplemente renombrar los archivos por completo, favicon-32-v2.png en vez de incrementar una query string, lo que garantiza un fallo de caché sin ninguna ambigüedad. Decidí no hacerlo, ya que eso significa actualizar cada referencia al nombre de archivo antiguo por todo app.html y dondequiera más que esté enlazado, frente a tocar un número en un solo sitio. La query string hace el mismo trabajo por una fracción de la edición.