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).
$ curl -I https://justoffbyone.com/posts/measuring-engineering-productivity
HTTP/2 301server: GitHub.comlocation: https://justoffbyone.com/posts/measuring-engineering-productivity/cache-control: max-age=600x-github-request-id: 33B9:3253BC:B93DB6:D68DCC:6900C8EAAnd with a trailing slash:
$ curl -I https://justoffbyone.com/posts/measuring-engineering-productivity/
HTTP/2 200server: GitHub.comcontent-type: text/html; charset=utf-8cache-control: max-age=600content-length: 70127The 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: 0and 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: 1OK, 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:
| Metric | With / | Without / | Overhead |
|---|---|---|---|
| Time to First Byte | 50ms | 65ms | +15ms |
| Total Time | 66ms | 81ms | +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 → 2ms2. TCP handshake → 50ms3. TLS handshake → 17ms4. Request: GET /posts/article5. Response: 301 Redirect → 20ms6. Follow redirect7. Request: GET /posts/article/ → 35ms8. Response: 200 OKTotal: ~124msAnd with:
1. DNS lookup: github.com → 2ms2. TCP handshake → 8ms3. TLS handshake → 14ms4. Request: GET /posts/article/5. Response: 200 OK → 34msTotal: ~58msThe 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:
# 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)doneThe -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
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 1fi
URL_WITHOUT_SLASH="$1"URL_WITH_SLASH="${1}/"
# Create temporary file for curl timing formatTIMING_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 slashecho "Testing: $URL_WITHOUT_SLASH"echo "-----------------------------------------------------------"curl -I "$URL_WITHOUT_SLASH" 2>&1 | head -15echo ""echo "Timing data:"curl -L -w "@$TIMING_FORMAT" -o /dev/null -s "$URL_WITHOUT_SLASH"echo ""echo ""
# Test WITH trailing slashecho "Testing: $URL_WITH_SLASH"echo "-----------------------------------------------------------"curl -I "$URL_WITH_SLASH" 2>&1 | head -15echo ""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)doneecho " done!"echo ""
# Calculate averagesavg_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 ""
# Cleanuprm "$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.