Step 4 — The redirect
The one endpoint the whole product exists for.
- Build the redirect endpoint and choose between 301, 302 and 307.
- Register the catch-all code route so it does not shadow other routes.
- Count clicks without slowing down the redirect.
Every command for this step
# no new files — edit handler.go and service.gogo run ./cmd/api# create a link and follow itCODE=$(curl -s -X POST localhost:8080/links -d '{"url":"https://go.dev"}' | jq -r .code)curl -si localhost:8080/$CODE | head -3curl -sL localhost:8080/$CODE -o /dev/null -w '%{url_effective}\n'curl -si localhost:8080/nope | head -1# check the click count went upcurl -s localhost:8080/links/$CODE | jq .clicks# and check for races while you are herego run -race ./cmd/apiWhat a redirect is
A redirect is an HTTP response that says "what you asked for is somewhere else, go here instead". The browser then requests the new address on its own. You send a status code in the 300s and a Location header holding the destination.
Creating a link happens once. A redirect happens every time someone clicks, and it is what users experience, so this is the endpoint to keep fast.
Which status code to send
| Status | Means | Browser behaviour |
|---|---|---|
| 301 Moved Permanently | this will never change | cached hard — later visits may skip your server entirely |
| 302 Found | temporary | not cached, comes back to you every time |
| 307/308 | same as 302/301 but keeps the HTTP method | used for non-GET requests |
The handler
func (h *Handler) redirect(w http.ResponseWriter, r *http.Request) { code := r.PathValue("code") l, err := h.svc.Resolve(r.Context(), code) if err != nil { // A plain text 404, not JSON — a person is looking at this in a browser. http.NotFound(w, r) return } http.Redirect(w, r, l.LongURL, http.StatusFound) // 302}Register it carefully
The redirect lives at the root, which means its pattern is very broad. Since Go 1.22, ServeMux picks the most specific matching pattern, so your other routes still win. Understand why rather than trusting it.
func (h *Handler) Routes(mux *http.ServeMux) { mux.HandleFunc("POST /links", h.create) mux.HandleFunc("GET /{code}", h.redirect) // the broad one}// "GET /links" is more specific than "GET /{code}", so it wins.// "GET /healthz" likewise. You do not need to register in any special order.Counting clicks without slowing the redirect
You want to know how often a link is used. The obvious way is to write to the store and then redirect, which makes every visitor wait for a write they do not care about.
Instead, send the redirect immediately and count in the background. The visitor gets the response sooner. The cost is that a crash can lose a few counts, which is acceptable for a click counter.
func (s *Service) Resolve(ctx context.Context, code string) (*Link, error) { l, err := s.store.ByCode(ctx, code) if err != nil { return nil, err } // Count in the background so the visitor does not wait for it. // NOTE: this goroutine must not use ctx as-is — that context is cancelled // the moment the response is sent, which would cancel the count as well. // WithoutCancel (Go 1.21+) keeps its values but drops the cancellation. bg := context.WithoutCancel(ctx) go func() { if err := s.store.Incr(bg, code); err != nil { slog.Error("click count failed", "code", code, "err", err) } }() return l, nil}Test it
CODE=$(curl -s -X POST localhost:8080/links -d '{"url":"https://go.dev"}' | jq -r .code)curl -si localhost:8080/$CODE | head -3# HTTP/1.1 302 Found# Location: https://go.devcurl -sL localhost:8080/$CODE -o /dev/null -w '%{url_effective}\n' # -L follows itcurl -si localhost:8080/nope | head -1 # 404- A redirect is a 3xx status plus a
Locationheader.http.Redirectwrites both. - Use 302, not 301. A cached 301 never reaches your server again, so you cannot count it or change it.
- Go 1.22 matches the most specific pattern, so
GET /linksbeatsGET /{code}. - Do the click count in the background, and do not give that goroutine the request context.
- 1Return a proper HTML 404 page for a missing code instead of plain text.
- 2Replace the goroutine-per-redirect with one buffered channel and a single worker draining it.
- 3Log the referrer and user agent alongside each click, then decide whether you should be storing that.
- 4Measure how much slower the redirect gets if you count synchronously instead.