HTTP 302 vs 303, the debugging nightmare

Decoding HTTP 302 and 303 Redirects: Unraveling the Web's Redirect Mysteries


5 min read

HTTP 302 vs 303, the debugging nightmare

you may be very well aware that there are many HTTP status codes, which convey meaning and miscellaneous context to the response returned to the client from the server.

variety of options and their cryptic academic descriptions might make you question differences and may drive you to use one for all cases, where unfortunately it might cause more headaches than convenience.

One such pair is 302 and 303. Being 3xx, it is obvious response is a redirect. But, did you know a single increment in the last digit changes the behavior drastically? The above leads you to the MDN docs. The sentence of interest is...

302 should not alter the Method or Body to a new Location

303 will issue a new GET, see for yourself

So, what? There are pretty annoying debugging and critical error bugs that might come when interchanging the codes without thinking twice. First, let's see it in action. Shall we?

See for yourself.

all code can be retrieved from this GitHub Gist

The scenario of interest is redirection. So, we will set up an endpoint, that will request a resource to another endpoint; the requested endpoint will then redirect to the final endpoint which will return another response. Feeling Dizzy ๐Ÿ˜ต or lost ๐Ÿ˜–? Here is the flow diagram โฌ‡๏ธ with numeric indicating flow order.

The concern is on status code of 2) redirect response and request method of 3) redirected request.

Tech stack

Since this is just a demo, I will use built-in http server of Bunjs and htmx.js to issue different HTTP methods to indicate potential real-world scenarios.

Setting up the server

In case I delete the gist in the future, here is the entire server code.

const server = Bun.serve({
    port: 3000,
    fetch(req) {
        const url = new URL(req.url);
        switch (url.pathname) {
            case "/":
                console.log("root access");
                return new Response(Bun.file("./index.html"))
            case "/302":
                console.log("302 endpoint");
                // 302 is the default behavior of `redirect`
                return Response.redirect("/end");
            case "/303":
                console.log("303 endpoint");
                return Response.redirect("/end", 303);
            case "/end":
                return new Response(`endpoint hit with HTTP method ${req.method}`);
                return new Response("404")

console.log(`Listening on http://localhost:${server.port} ...`);

And, here is the index file that will be served and have the button to issue requests and demonstrate the effect.


        <title>302 and 303 Diff Demo</title>

        <h1>SUTBLE Difference between Redirect 302 and 303</h1>

        <button hx-delete="/302" hx-target="next #target" hx-swap="innerHTML" type="button">
            issue request to 302

        <button hx-delete="/303" hx-target="next #target" hx-swap="innerHTML" type="button">
            issue request to 303
        <div id="target" style="margin-top: 10px;">


        <script src=""></script>

for the above code, index.html and index.ts should be on the same directory level

you may run sever with hot argument to allow hot reloading while tinkering. bun --hot run index.ts

The showdown

When clicking on the 302, a text will appear below the buttons. Read that. Should be like...

Now, click on the other one.

Do you notice any difference? In case you do not, the methods are different. Let's take a look at the index.html again, shall we?

Buttons to request the resource are as follows(I cleared unwanted attributes for brevity)...

<button hx-delete="/302" type="button">
    issue request to 302

<button hx-delete="/303" type="button">
    issue request to 303

The hx-delete is from htmx, which will request a DELETE HTTP Request to /302 or /303 endpoints depending on the button the user clicked. Here both of these endpoints are examples for the endpoint 1 in the diagram given earlier.

Let's examine the server code for both of these endpoints.

case "/302":
    console.log("302 endpoint");
    // 302 is the default behavior of `redirect`
    return Response.redirect("/end");
case "/303":
    console.log("303 endpoint");
    return Response.redirect("/end", 303);

As you can understand, we log the request to std-out and then return a redirect response. Each returns with a different status code but the same Location the /end. The /end returns a response with a string indicating the method of the request.

case "/end":
    return new Response(`endpoint hit with HTTP method ${req.method}`);

htmx will then swap the returned value with the innerHTML of the div with the id target. As I said earlier the http method is different! this is by design and what the standard also drafts out. So what's the catch?

Wolf in sheep cloth

I wanted to write about this as I have stumbled upon this myself and have successfully lived 2 hours of nightmare debugging my side project. This is how it all happened...

  1. I had a PUT handling handler at an endpoint

  2. after a successful update, I redirect the request to the details view

  3. details view is a resource tied to GET method

But to my surprise, I was getting 403 Method Not Allowed response back. ๐Ÿ˜ฌ. URL was right but the http method was wrong. I didn't know why the browser was issuing PUT method to redirect URL.

I tested this with some sites but for those browser-issued GET request. ๐Ÿคทโ€โ™‚๏ธ. After doing all sorts of debugging and resorted to reverse engineering. I wanted what the differences are between my requests/responses and this particular site's requests/responses. I diffed and to my surprise, apart from dates, content length and such dynamic headers, the HTTP message was different along with the code.

I MDN-d the code and turns out the default behavior of the redirect is to have a 302 status code, which means found in another place! So the browser redirected the request body and all with only the URL changed. ๐Ÿคฆโ€โ™‚๏ธ

My tragedy aside; I wanted to stress out that before debugging in, make sure what the framework does by default so you know what's happening. A similar case came to me a few years back.

I wanted to compile surrealdb inside a Tauri app. But the compilation failed and it turns out devs cross-compiled it. So, I dug deeper and found an interesting mechanism of how GNU-utils ruined my day. You may read more here, in this blog which got me third prize in the Hashnode debugathon ๐Ÿค—

Wrap up

That's all I wanted to say for now. See you on another one, till then it's meTheBE, signing off!