Off by One Logo
Overview

Avoiding the Trailing Slash Tax on Github Pages and Astro

October 28, 2025
6 min read

I was browsing my own blog on my phone when I noticed something odd: some pages loaded instantly, while others had a barely perceptible delay. Even stranger, those slower pages had a subtle font flicker as they loaded. Annoying!

Being the geriatric millennial I am, I knew I had to test this on my computer. And then, things got weirder. My site, hosted on Github Pages felt faster than my local development server on those fast pages. That didn’t make sense—local development should be instant, even if Astro is doing on-demand rendering.

The font flicker was a clue. Fonts don’t flicker unless the browser is making multiple requests. What was going on?

Sure enough, every URL without a trailing slash was triggering a 301 redirect. That single missing character was costing me up to 60% in page load performance during some test runs. I fixed it and wanted to share how.

Here’s what the font flicker looks like in action:

With redirect:

Without redirect:

Annoying!

A disclaimer is in order. Now I don’t exactly think the flash of unstyled text is caused by the redirect itself. However, I have a suspicion that the redirects are causing an issue with the prefetching mechanisms, which is exacerbating the issue. Nevertheless, it was what triggered me to actually dive deeper into what is going on.

The Problem

GitHub Pages seems to treat directories and files differently:

  • /posts/my-article → 301 redirect → /posts/my-article/
  • /posts/my-article/ → 200 OK (direct response)

This isn’t new. Everyone has hates / loves trailing slashes. It’s one of those weird quirks of working on the web. And of course, it’s a thing on Github Pages you need to know about too. Others documented the same issue back in 2016 with Jekyll. So, how do we put some numbers on this issue? curl to the rescue!

This is my post on curl without a trailing slash (truncatated for effect).

Terminal window
$ curl -I https://justoffbyone.com/posts/measuring-engineering-productivity
HTTP/2 301
server: GitHub.com
location: https://justoffbyone.com/posts/measuring-engineering-productivity/
cache-control: max-age=600
x-github-request-id: 33B9:3253BC:B93DB6:D68DCC:6900C8EA

And with a trailing slash:

Terminal window
$ curl -I https://justoffbyone.com/posts/measuring-engineering-productivity/
HTTP/2 200
server: GitHub.com
content-type: text/html; charset=utf-8
cache-control: max-age=600
content-length: 70127

The first request gets a 301 Moved Permanently response, forcing the browser to make a second request. The second request goes straight to the content. Getting closer!

The Performance Impact

Let’s measure the actual cost. Here’s a pathological but still a real set of requests, with a trailing slash:

time_namelookup: 0.001993s
time_connect: 0.008183s
time_appconnect: 0.022139s
time_pretransfer: 0.022189s
time_redirect: 0.000000s
time_starttransfer: 0.033561s
----------
time_total: 0.045192s
num_redirects: 0

and without

time_namelookup: 0.002011s
time_connect: 0.052185s
time_appconnect: 0.068896s
time_pretransfer: 0.069005s
time_redirect: 0.079601s <-- the culprit!!
time_starttransfer: 0.089141s
----------
time_total: 0.101227s
num_redirects: 1

OK, fine, but how does it look when you re-run the tests over and over again. Here’s me running the above test 50 times:

MetricWith /Without /Overhead
Time to First Byte50ms65ms+15ms
Total Time66ms81ms+14ms
Performance Cost+20%

That’s right: missing a single character made my site 20% slower! Unacceptable!!

The Real-World Cascade

Let’s get even more technical. Here’s what happens when a user clicks a link to your blog post. Again, here’s a pathological but albeit real set of requests.

Without a trailing slash:

1. DNS lookup: github.com → 2ms
2. TCP handshake → 50ms
3. TLS handshake → 17ms
4. Request: GET /posts/article
5. Response: 301 Redirect → 20ms
6. Follow redirect
7. Request: GET /posts/article/ → 35ms
8. Response: 200 OK
Total: ~124ms

And with:

1. DNS lookup: github.com → 2ms
2. TCP handshake → 8ms
3. TLS handshake → 14ms
4. Request: GET /posts/article/
5. Response: 200 OK → 34ms
Total: ~58ms

The redirect forces a full round trip through the network stack before you can even start downloading the page.

Testing Your Own Site

I ran my tests via a script Claude and I created to test for trailing slash behavior. The full script is available down below and includes HTTP header inspection, detailed timing breakdowns, and multi-run averaging.

Here’s the core logic that measures the performance difference:

Terminal window
# Define a format...
TIMING_FORMAT=$(mktemp)
cat > "$TIMING_FORMAT" << 'EOF'
{
"dns_lookup": %{time_namelookup},
"tcp_connection": %{time_connect},
"tls_handshake": %{time_appconnect},
"time_redirect": %{time_redirect},
"time_to_first_byte": %{time_starttransfer},
"total_time": %{time_total},
"num_redirects": %{num_redirects}
}
EOF
# Various setup things...
for i in {1..50}; do
time_without=$(curl -L -w "%{time_total}" -o /dev/null -s "$URL_WITHOUT_SLASH")
times_without+=($time_without)
time_with=$(curl -L -w "%{time_total}" -o /dev/null -s "$URL_WITH_SLASH")
times_with+=($time_with)
done

The -L flag tells curl to follow redirects, and the -w flag outputs timing information. By running multiple iterations, we get a more accurate average that accounts for network variability.

Let’s Fix It

Unfortunately, you can’t really fix this via Astro only, as far as I can tell. The Astro trailing slash config isn’t about “fixing” GitHub Pages—it will always redirect URLs without trailing slashes, which I assume due to Github’s web server configuration.

For Astro

astro.config.ts
export default defineConfig({
trailingSlash: 'always',
})

So, if we can’t eliminate redirects in production on Github Pages, our best option is to not cause them at all in the first place. That means, we need to make sure we always use links with trailing slashes.

Luckily, at the time I’ve recognized this issue, I didn’t have too much content. I also enjoyed the opportunity to go deeper into the Astro code and familiarize myself with how it works.

I ended up doing two things:

1. Created a helper function to wrap (almost) all link generation:

export function ensureTrailingSlash(href: string): string {
// Don't modify empty strings, external URLs, or anchor-only links
if (!href || href.startsWith('http') || href.startsWith('#')) {
return href
}
// Handle query strings and hash fragments
const queryIndex = href.indexOf('?')
const hashIndex = href.indexOf('#')
// ... logic to preserve query strings and hash fragments
// Don't add trailing slash to files with extensions
if (pathPart.endsWith('/') || /\.[a-z]+$/i.test(pathPart)) {
return href
}
return `${pathPart}/`
}

Now every navigation component uses this function, ensuring all generated links have trailing slashes. It handles edge cases like query strings (/search/?q=test), hash fragments (/#top), and file extensions (/rss.xml). Using Claude and Codex helped making the changes quite fun and easy.

2. Added an automated test to catch hardcoded links in blog posts, like this one:

it('should have trailing slashes on all internal /posts/ links', async () => {
const mdxFiles = globSync('src/content/blog/**/*.mdx')
for (const file of mdxFiles) {
const content = readFileSync(file, 'utf-8')
const lines = content.split('\n')
lines.forEach((line, index) => {
// Match internal /posts/ links without trailing slash
const regex = /\/posts\/([^/\s)]+)\)/g
// ... report any violations
})
}
})

This test runs on every commit and catches any hardcoded links in my MDX files that are missing trailing slashes. It’s already saved me multiple times from accidentally introducing redirects, including on this post. Oops!

Ending Thoughts

I was kind of surprised that a mere 14ms average felt so different. Here’s my guess on why it seemed to matter that much. First of all, we know from cognitive science research that humans can detect delays up to 100ms. 14ms isn’t much on its own, but when you’re navigating between multiple pages, it adds up. And during worse network conditions (like when I hit 40%, even 60% overhead), you’re definitely crossing into perceptible territory. Especially if that difference tips you from 80ms to 120ms.

Also, these tests were from a fast connection in New York on a fiber connection on a high-end wifi router. On 4G or 3G? You’re looking at 200-500ms per redirect. And if you’re far from GitHub’s CDN edge, that cost multiplies.

But mostly, it was that font flicker. Once you see it, you can’t unsee it.

Honestly? I did this because it’s fun to optimize things. But I was pleasantly surprised by how much faster the site feels now. I haven’t dug too deeply into it, but I suspect that when all your links return proper 200s, the browser gets better at prefetching or caching across resources.

The numbers show up to a 60% improvement in some test runs, but the real win is that font flicker is gone and navigation feels instant. Sometimes the small details compound into something noticeable.

A Note on the Numbers

Very annoyingly, when I ran the tests first, it was around 40 milliseconds difference. But then, when it was time to actually publish the post and I ran the tests again, the difference became around 14 milliseconds. Now, I could have posted the original numbers and I’d be in the right—but it didn’t feel right. I love good provocative content as much as the next guy, but hey, you know, you gotta be honest.

The Full Test Script

Here’s the complete script I wrote with some help from Claude to test trailing slash behavior. It tests both URLs, shows the HTTP headers, provides detailed timing breakdowns, and runs 50 iterations for statistical accuracy:

#!/bin/bash
# With some edits via Claude Code
set -e
if [ $# -eq 0 ]; then
echo "Usage: $0 <url-without-trailing-slash>"
echo "Example: $0 https://justoffbyone.com/about"
exit 1
fi
URL_WITHOUT_SLASH="$1"
URL_WITH_SLASH="${1}/"
# Create temporary file for curl timing format
TIMING_FORMAT=$(mktemp)
cat > "$TIMING_FORMAT" << 'EOF'
{
"dns_lookup": %{time_namelookup},
"tcp_connection": %{time_connect},
"tls_handshake": %{time_appconnect},
"time_redirect": %{time_redirect},
"time_to_first_byte": %{time_starttransfer},
"total_time": %{time_total},
"num_redirects": %{num_redirects},
"http_code": %{http_code},
"redirect_url": "%{redirect_url}"
}
EOF
echo "Testing trailing slash redirect behavior and performance"
echo "==========================================================="
echo ""
# Test WITHOUT trailing slash
echo "Testing: $URL_WITHOUT_SLASH"
echo "-----------------------------------------------------------"
curl -I "$URL_WITHOUT_SLASH" 2>&1 | head -15
echo ""
echo "Timing data:"
curl -L -w "@$TIMING_FORMAT" -o /dev/null -s "$URL_WITHOUT_SLASH"
echo ""
echo ""
# Test WITH trailing slash
echo "Testing: $URL_WITH_SLASH"
echo "-----------------------------------------------------------"
curl -I "$URL_WITH_SLASH" 2>&1 | head -15
echo ""
echo "Timing data:"
curl -L -w "@$TIMING_FORMAT" -o /dev/null -s "$URL_WITH_SLASH"
echo ""
echo ""
echo "Performance comparison (50 runs each):"
echo "==========================================================="
echo ""
declare -a times_without=()
declare -a times_with=()
echo -n "Running tests"
for i in {1..50}; do
echo -n "."
time_without=$(curl -L -w "%{time_total}" -o /dev/null -s "$URL_WITHOUT_SLASH")
times_without+=($time_without)
time_with=$(curl -L -w "%{time_total}" -o /dev/null -s "$URL_WITH_SLASH")
times_with+=($time_with)
done
echo " done!"
echo ""
# Calculate averages
avg_without=$(printf '%s\n' "${times_without[@]}" | awk '{sum+=$1} END {print sum/NR}')
avg_with=$(printf '%s\n' "${times_with[@]}" | awk '{sum+=$1} END {print sum/NR}')
# Calculate difference (set scale to preserve fractional seconds)
difference=$(echo "scale=4; $avg_without - $avg_with" | bc)
percentage=$(echo "scale=1; ($difference / $avg_with) * 100" | bc)
echo "Average time WITHOUT trailing slash: ${avg_without}s"
echo "Average time WITH trailing slash: ${avg_with}s"
echo "Difference: ${difference}s (${percentage}% slower)"
echo ""
# Cleanup
rm "$TIMING_FORMAT"

Save this as test-trailing-slash.sh, make it executable with chmod +x test-trailing-slash.sh, and run it with your URL as an argument.