A tournament bracket page in my sports SaaS threw the internal-error screen when someone hit it with a bad bracket ID. Not a 404, not a friendly “that bracket doesn’t exist” message. The full error page: the generic catch-all screen that means something detonated server-side. The fix was four lines, but the reason it worked is one of those Classic-ASP details that bites you exactly once and then you remember it forever.

What the error page actually is

Every page in this app runs its logic inside a wrapper that looks like try { ... } catch(e) { ReturnBug(e); }. When anything throws, ReturnBug logs the exception to an error table and then hands the user off to an internal-error page. That page is the catch-all. It’s the app’s way of saying “I have no idea what just happened, here is a generic apology.”

So when a user reported the bracket page showing that screen on a bad ID, the real question wasn’t “why is the bracket missing.” It was “what is throwing an unhandled exception, because a missing bracket should never reach the global catch.” The error page isn’t the bug. The error page is the catch handler doing its job. The bug is whatever threw inside the try.

What threw

The page handled a missing or unmatched bracket ID the obvious way. Bad ID, bounce the user back to a sensible landing page. The code built the redirect target out of query-string fragments:

var redirectLink = '/admin/?p=' + redirectPage + '&' + GetQry('p');
Response.Redirect(redirectLink);

On the surface this is reasonable. Two problems are stacked here, and they compound.

First, the URL is hand-assembled from redirectPage plus whatever GetQry('p') returns. When you’re already in the missing-ID error path, those inputs are exactly the ones most likely to be empty or malformed. You’re building a redirect target out of the same broken state that got you here.

Second, and this is the real killer: Response.Redirect doesn’t re-route anything server-side. It issues an HTTP 302 by writing a Location response header and telling the browser to go fetch a different URL. Headers have to go out before the response body. You can only write them while nothing has been flushed yet.

In a buffered Classic-ASP page, “before the body starts” is a narrower window than you’d think. This redirect lived in the bracket init function, which runs inside the page’s try block, well into page execution. By the time it fired, output could already be on the wire. When that happens, Response.Redirect can’t add its Location header because the headers have already left, so it fails or throws. The throw bubbles up to the global catch, ReturnBug runs, and the user sees the internal-error screen. The redirect that was supposed to send them away gracefully is the thing blowing up the page.

I’ll be honest about confidence here. I don’t have a captured stack trace pinning down the exact way the 302 misbehaved on this request, and I found no explicit Response.Buffer = false in the page. What I can say: the redirect was fired from deep inside page execution, in the error branch, and that is precisely the situation where a header-dependent 302 can fail. Swapping it for a mechanism with no header dependency made the symptom go away. The likely mechanism is the one above.

Why Server.Transfer doesn’t have this failure mode

Server.Transfer is a different mechanism. There is no 302, no Location header, no second HTTP round-trip, and no browser navigation. Execution transfers to another page on the server in the same request, and that page’s output becomes the response. It avoids the specific late-header redirect path that made Response.Redirect a poor fit for this error branch, though the target and any output already written still need deliberate handling.

The fix replaced both Response.Redirect calls with a small helper the codebase already had:

function InternalRedirect(url) {
if (!url) url = "home.asp";
Server.Transfer(url);
}

Both call sites just call InternalRedirect() with no argument, so the helper falls through to its default of home.asp. That default kills the second problem too: the brittle URL-building code is gone, and the not-found path now transfers to a known-good page instead of negotiating a 302 from inside a half-flushed response.

The net diff in the bracket file was 4 insertions and 24 deletions. Most of the 24 deleted lines were accumulated double and triple blank lines, the whitespace cruft that builds up in a decade-old file nobody runs through a formatter. The actual code removed was the hand-assembled URL block plus the two Response.Redirect calls. Shipped as a hotfix the same morning.

When each one is actually right

This isn’t “always use Server.Transfer.” They do different jobs, and the URL behavior is the tell.

Response.Redirect changes the URL in the browser’s address bar, because the browser genuinely navigates to the new location. That’s what you want for a real navigation: after a successful POST, when you want the address bar to reflect the new page, when you’re sending someone to an entirely different area and a fresh request is correct. It can also point at an external site. The cost is that it only works while you can still write headers, which in practice means early in the page before any output.

Server.Transfer keeps the original URL in the browser, because the browser never finds out a transfer happened. The user requested the bracket page, and the address bar still says the bracket page, even though they’re now looking at the home page’s output. It only works for a target inside the same app on the same server, and the original page’s already-emitted output shares one response with the transfer target, so you have to be deliberate about what’s been written. What you buy is that it has no header dependency to trip over.

So the rule I’d actually write down: if you’re redirecting from the top of a page before any output, and you want the browser URL to change, Response.Redirect is correct. If you’re redirecting from the middle of a page where output may already have started, and you just need to hand the request off to a safe internal page, Server.Transfer is the safer move. Mid-page plus buffered output is exactly where Response.Redirect bites, and that’s exactly where a bad-ID guard tends to live.

This generalizes past Classic ASP. Anywhere you have buffered output and a redirect mechanism that depends on writing a header late in the lifecycle, you have the same trap. The fix is always the same shape: re-route in a way that doesn’t depend on headers you may have already flushed.

The part I want to defend: the honest PENDING

There’s one more line in that commit worth talking about, because a lot of people would have quietly deleted it before pushing. I left a comment in:

// PEENDING - home page loads with bugs, needs better handler

Typo and all. Here’s the thing. The hotfix stopped the bracket page from throwing the internal-error screen. That was the acute symptom, the thing a user was hitting in production, and it’s genuinely fixed. But the page I’m now transferring people to, the home page, has its own problems. I treated the symptom. I did not fix the root cause, which is that the fallback destination isn’t clean either.

I could have shipped the fix and said nothing. The bracket page works now, ticket closed, move on. But that buries a known problem inside a commit that looks finished. The next person to touch this code (probably me, six months from now) would have no signal that the home page handoff is a known soft spot rather than a deliberate, fully-vetted choice.

A flagged deferral beats a silent one. “I treated the symptom and here’s the root cause I’m punting on” is honest engineering. “Fixed” when you’ve only treated the symptom is a lie you tell your future self. The // PENDING is the difference between a deferral you wrote down and a deferral you forgot. One of those you can come back to. The other one ambushes you. The typo I’ll fix next time I’m in there. The honesty I’m keeping.

The takeaway

Two things from one four-line diff.

The mechanical one: in Classic ASP, and anywhere with buffered output, Response.Redirect can fail when output has already started, because its 302 needs a Location header you may no longer be able to write. Server.Transfer re-routes server-side with no header dependency, so it survives mid-page. Pick by URL behavior and by where in the lifecycle you’re redirecting, not by habit.

The human one: when you’ve only treated the symptom, say so in the code. Leave the honest // PENDING. The deferral you wrote down is a note to your future self. The deferral you didn’t is a trap you set for them.