DEV Community

takahiro hashito
takahiro hashito

Posted on

I merged three tools with 301 redirects on Firebase Hosting, and every old URL now takes two hops

Background

I run a site of small browser-based tools, and last week I folded three near-duplicate tools into the tools that already covered them.
For example, the calendar QR generator (calqr) became an option inside the event QR generator.

https://hashitosystem.com/en/tools/eventqr/

The old pages were deleted, so the old URLs needed permanent redirects.
I added them, opened an old URL in a browser, landed on the new tool, and moved on.
This morning I checked the raw HTTP responses instead of the browser, and every old URL was taking two redirects to arrive, not one.

The setup

The site is a static site on Firebase Hosting.
Firebase Hosting is Google's static hosting service, and it is configured through a firebase.json file in the project.
Two settings matter here.
cleanUrls: true serves /tools/eventqr/index.html without the index.html part.
trailingSlash: true makes the canonical form of every page end with /, and redirects the slash-less form to it.

The merge added redirect rules in the hosting.redirects array of firebase.json.
Each old tool got two rules: one for the bare path, and one with /** so that anything under the old directory is caught too.
Here is the pair for one of them, exactly as committed:

{
  "source": "/tools/calqr",
  "destination": "/tools/eventqr",
  "type": 301
},
{
  "source": "/tools/calqr/**",
  "destination": "/tools/eventqr",
  "type": 301
}
Enter fullscreen mode Exit fullscreen mode

A 301 is a permanent redirect.
Search engines are expected to move the old URL's ranking signals to the destination, which is the whole reason for not just returning 404.

Seeing every hop

A browser follows redirects silently, and so does fetch by default, so neither shows you the chain.
In Node.js 18 or later, passing redirect: "manual" to fetch returns the redirect response itself, with its Location header readable.
The script below requests each path once and prints the status code and where it points.
Save it as redir.mjs and run it with node redir.mjs; change base and paths to your own site.

// redir.mjs: print the first response for each URL without following redirects
const base = "https://hashitosystem.com";
const paths = [
  "/tools/calqr",
  "/tools/calqr/",
  "/tools/calqr/index.html",
  "/tools/calqr/foo",
  "/tools/eventqr",
  "/tools/eventqr/",
];
for (const p of paths) {
  const r = await fetch(base + p, { redirect: "manual" });
  console.log(r.status, p, "->", r.headers.get("location") ?? "");
}
Enter fullscreen mode Exit fullscreen mode

This is the output I got this morning:

301 /tools/calqr -> /tools/eventqr
301 /tools/calqr/ -> /tools/eventqr
301 /tools/calqr/index.html -> /tools/eventqr
301 /tools/calqr/foo -> /tools/eventqr
301 /tools/eventqr -> /tools/eventqr/
200 /tools/eventqr/ ->
Enter fullscreen mode Exit fullscreen mode

The first four lines are my redirect rules working as written: every variant of the old URL goes to /tools/eventqr.
The fifth line is the catch.
/tools/eventqr is not the canonical URL on this site, so trailingSlash: true answers it with another 301 to /tools/eventqr/.
So the real path for an old link is /tools/calqr to /tools/eventqr to /tools/eventqr/: two hops.

It was not new, either.
An older merge on the same site (uuidgen into uuid) was written the same way and has the same chain.

Confirming the fix on a site with the same settings

I have another site on Firebase Hosting with the same cleanUrls: true and trailingSlash: true, where the redirect destinations were written with a trailing slash (for example "destination": "/tools/amidacuji/").
Running the same script against it gave this:

301 /amidacuji -> /tools/amidacuji/
301 /amidacuji/ -> /tools/amidacuji/
200 /tools/amidacuji/ ->
Enter fullscreen mode Exit fullscreen mode

One hop, straight to the page that returns 200.
So the difference really is just the trailing slash in destination, and the fix for the merged tools is to write /tools/eventqr/ instead of /tools/eventqr in all twelve rules.
I have not deployed that change yet; it goes through a pull request on that repository like every other change.

Why bother about one extra hop

For a human, two hops cost a few extra round trips and nobody notices.
The reason I care is that the redirect is there for crawlers.
Google's documentation says Googlebot follows a limited number of redirect hops, and every hop is one more request out of the crawl it spends on a small site like mine.
A redirect whose only job is to pass a page's history to its replacement should do it in one step, not depend on a second rule I did not write on purpose.

Takeaway

When trailingSlash is on, a redirect destination must be the exact canonical URL, including the final /, or Firebase Hosting quietly adds a second 301.
Opening the old URL in a browser cannot show you this, because it follows the whole chain and only shows the last page.
Probe each old URL with fetch(url, { redirect: "manual" }) and check that the very first response points at a URL that returns 200.


This article is about my own side project. It was written with AI assistance.

Top comments (0)