Skip to content

Un 301 en cada clic: la trailing slash que ralentizaba toda la navegación interna

Published:

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
Pestaña Network de Chrome DevTools en skills.addy.ie/compare/ antes del fix: cada ruta interna aparece dos veces, primero con estado 301 Moved Permanently (document / Redirect) y luego con 200 OK, diez peticiones para cinco navegaciones.
Antes del fix: cada navegación interna genera un 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 imagen

Cada 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:

  1. Astro construye en directory format por defecto. Cada página se emite como /<ruta>/index.html, así que su URL canónica lleva barra final: /skills/.
  2. 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.
  3. 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
Pestaña Network de Chrome DevTools en skills.addy.ie/compare/ tras el fix: la home y las rutas internas (/skills/, /docs/getting-started/, /lifecycle/, /teach/, /compare/) responden todas con 200 OK directo, sin ninguna fila 301.
Después del fix: las mismas rutas devuelven 200 directo, sin una sola redirección. Las dos peticiones por clic se quedan en una. Ampliar imagen

La 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:

Lo que me llevo

Tres cosas prácticas de este caso:

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.


Next Post
Keep-Alive no es Cache-Control: anatomía de una cascada de red