Estaba revisando skills.addy.ie, el sitio del proyecto Agent Skills en el que colaboro, y lo hacía como casi siempre: con las DevTools abiertas. Es un tic profesional; las abro por costumbre buscando cosas que mejorar, no porque notara que algo iba lento. Y así, casi de refilón, me saltó algo a la vista en la pestaña de red: cada enlace interno que pulsaba provocaba una redirección 301 antes de cargar la página. No una, no en un sitio concreto: en todas. /skills, /lifecycle, /compare, /docs/getting-started… todas respondían primero con un 301 hacia su versión con barra final.
Parece inofensivo, pero un 301 en cada navegación es un round trip entero que quien navega paga antes de que la página empiece siquiera a descargarse. Acabé abriendo una PR al repositorio para corregirlo. Vamos a ver qué encontré, por qué pasaba, y por qué el fix obvio no era del todo el fix correcto.
Lo que vi en las DevTools
En la pestaña Network, navegando por el sitio y pulsando los enlaces del menú, el patrón se repetía en cada clic:
GET /skills 301 220 ms → /skills/
GET /skills/ 200 65 ms
GET /docs/getting-started 301 406 ms → /docs/getting-started/
GET /docs/getting-started/ 200 47 ms
GET /lifecycle 301 251 ms → /lifecycle/
GET /lifecycle/ 200 99 ms
GET /teach 301 440 ms → /teach/
GET /teach/ 200 46 ms
GET /compare 301 507 ms → /compare/
GET /compare/ 200 58 ms
301 Moved Permanently hacia la URL con barra final y, después, el 200 real. El 301 de /compare se lleva 507 ms él solo. Ampliar imagenCada navegación son dos peticiones: primero la URL sin barra, que devuelve un 301 con un Location apuntando a la misma URL con barra final, y después la URL buena, que ya devuelve el 200 con el documento. El servidor es Netlify.
El coste de esos 301 iba de 220 ms a 507 ms por clic, y sumaban 1,82 segundos de latencia solo en saltos de redirección en una sesión de cinco navegaciones. Y no es tiempo de descargar nada útil: si miro el desglose del 301 de /compare, casi todo es wait, el tiempo de respuesta del servidor:
blocked 1 ms
send 0 ms
wait 505 ms
receive 1 ms
Sin DNS ni handshake (la conexión ya estaba abierta y reutilizada), el 301 es un round trip completo cuyo único trabajo es contestar “esta página está un carácter más allá, vuelve a pedirla con la barra”. El documento real no empieza a llegar hasta la segunda petición.
Cómo afecta a la UX
Un 301 aislado no se nota. El problema es que aquí ocurría en cada navegación interna, así que el coste no es puntual, es un peaje que se paga una y otra vez durante toda la visita. Quien navega pulsa un enlace y, antes de ver nada, el navegador hace un viaje de ida y vuelta al servidor que no dibuja ni un solo píxel.
Y ese coste empeora justo donde más duele: en redes con latencia alta. En una conexión móvil o lejos del edge de Netlify, un round trip de más no son 200 ms, son fácilmente 400 o 500 ms por clic. Y lo que medí fue con buena conexión, así que cuenta como uno de los mejores casos, no el peor. Si lo multiplicamos por una sesión de diez o quince navegaciones, estamos consumiendo segundos de espera acumulada que no aportan nada.
El primer fix que se me ocurrió
Lo primero que pensé, viendo que el 301 siempre iba de /ruta a /ruta/, fue lo evidente: cambiar los enlaces para que ya apunten a la URL con barra final. Si el enlace apunta directamente a /skills/, no hay nada que redirigir.
<!-- ❌ Bad: link triggers a 301 to /skills/ -->
<a href="/skills">Browse skills</a>
<!-- ✅ Good: link points straight to the canonical URL -->
<a href="/skills/">Browse skills</a>
Es correcto, pero es tratar el síntoma enlace por enlace. Antes de tocar nada quería entender por qué el sitio generaba enlaces sin barra si su URL canónica sí la llevaba. Si parcheamos sin entender la causa, el problema se reproduce en cuanto alguien añade el siguiente enlace sin barra.
Clonar el repo y entender el contexto
Cloné el repositorio y me apoyé en Claude Code para mapear de dónde salían los enlaces y cómo estaba configurado el build. La causa no estaba en un enlace suelto, sino en la combinación de tres cosas:
- Astro construye en
directoryformat por defecto. Cada página se emite como/<ruta>/index.html, así que su URL canónica lleva barra final:/skills/. - Los enlaces internos estaban escritos sin la barra. Y la mayoría no eran literales sueltos, sino generados dinámicamente (los arrays de navegación, las tarjetas del catálogo), por eso no eran evidentes a simple vista.
- Netlify, por defecto, canonicaliza con un 301. Al pedir
/skills, el host responde con una redirección hacia el/skills/que sí existe como directorio. Es el comportamiento estándar del hosting para servir salida en formato directorio.
Falta la pieza que explica por qué esto no se veía en local: el servidor de desarrollo de Astro, con el trailingSlash: 'ignore' que trae por defecto, sirve tanto /skills como /skills/ con un 200 directo. La redirección solo aparece en el hosting estático de producción. En el entorno de desarrollo, todo parecía perfecto. El bug vivía justo en el hueco entre cómo sirve las URLs el dev server y cómo las sirve Netlify.
La solución
El fix tiene dos partes, y la primera es la que evita que el problema vuelva.
1. Hacer la convención explícita en astro.config.mjs
export default defineConfig({
trailingSlash: "always",
// ...
});
Con trailingSlash: 'always' pasan dos cosas buenas: la convención de URLs queda declarada de forma explícita, y el dev server empieza a redirigir también. Es decir, el desajuste se vuelve reproducible en local, y cualquier enlace futuro sin barra se detecta antes de llegar a producción. Deja de ser un bug fantasma que solo existe en el entorno de producción.
2. Añadir la barra final a todos los enlaces internos
Los literales son directos, pero los que de verdad generaban la mayoría de redirecciones eran los dinámicos, como las tarjetas del catálogo de skills:
{/* ❌ Bad */}
href={`/skills/${skill.slug}`}
{/* ✅ Good */}
href={`/skills/${skill.slug}/`}
Lo mismo en los arrays de datos de Nav.astro y Footer.astro, y en los enlaces literales de las páginas. Los enlaces externos y los assets (.svg, .png, .pptx…) se quedan intactos: la barra final es cosa de rutas internas, no de ficheros.
Un matiz sobre SEO
Quitar el 301 de la navegación interna no significa eliminar el 301 del sitio, y así debe ser. Las URLs sin barra que ya estuvieran indexadas o enlazadas desde fuera van a seguir recibiendo el 301 de Netlify hacia su versión canónica, que es exactamente la canonicalización correcta para SEO: una sola URL válida por página, sin contenido duplicado. Lo que arregla la PR es el salto en la navegación interna, que era la fuente de la latencia que se llevaba quien ya estaba dentro del sitio.
Cómo verificarlo
La comprobación no es “confía en mí”, es medible. Validé el antes y el después capturando la actividad de la pestaña Network en la misma sesión de navegación y comparando los dos HAR con ayuda de Claude Code. Tras el fix, queda así:
GET / 200 86 ms
GET /skills/ 200 68 ms
GET /docs/getting-started/ 200 56 ms
GET /lifecycle/ 200 52 ms
GET /teach/ 200 48 ms
GET /compare/ 200 43 ms
200 directo, sin una sola redirección. Las dos peticiones por clic se quedan en una. Ampliar imagenLa carga inicial y las cinco navegaciones internas: seis 200 directos, cero redirecciones. El peaje del round trip por clic desaparece.
¿Y cómo evitar que se vuelva a colar sin depender de abrir las DevTools a mano? Merece la pena automatizar la vigilancia:
trailingSlash: 'always'es la primera red de seguridad: el dev server redirige, así que un enlace sin barra se nota en local o en los tests E2E antes de desplegar.- Una regla de lint o un
grepsobre eldisten CI que falle si aparece un enlace interno sin barra. - La auditoría Avoid multiple page redirects de Lighthouse señala las cadenas de redirección en producción.
Lo que me llevo
Tres cosas prácticas de este caso:
- El dev server te puede mentir por omisión. No porque falle, sino porque sus valores por defecto son más permisivos que los del hosting. Merece la pena abrir las DevTools contra el sitio real desplegado, no solo contra
localhost: hay problemas (redirecciones, cabeceras, caché) que solo existen en producción. - La consistencia de la trailing slash importa más de lo que parece. No es cosmética: un desajuste entre la URL canónica y los enlaces internos se paga en un round trip por clic. Elige una convención y decláralo de forma explícita en la config, para que las herramientas la hagan cumplir por ti.
- Antes de parchear el síntoma, entiende la causa. Cambiar los enlaces a mano habría funcionado hoy y se habría roto con el siguiente enlace que alguien añadiera sin barra.
trailingSlash: 'always'convierte el criterio en algo que el propio proyecto vigila.
Si tienes un sitio en Astro sobre Netlify (o cualquier host que canonicalice directorios), abre las DevTools, navega un poco por dentro y mira la columna de estado. Si ves 301 donde esperabas 200, ya sabes por dónde empezar.