Dev Skills

Step 4 — The redirect

The one endpoint the whole product exists for.

Topic 62 of 98 · 5 min to read · 50 min to build
After this topic you can
  • 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

terminal — run these in order
# no new files — edit handler.go and service.go
go run ./cmd/api
# create a link and follow it
CODE=$(curl -s -X POST localhost:8080/links -d '{"url":"https://go.dev"}' | jq -r .code)
curl -si localhost:8080/$CODE | head -3
curl -sL localhost:8080/$CODE -o /dev/null -w '%{url_effective}\n'
curl -si localhost:8080/nope | head -1
# check the click count went up
curl -s localhost:8080/links/$CODE | jq .clicks
# and check for races while you are here
go run -race ./cmd/api

What 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

StatusMeansBrowser behaviour
301 Moved Permanentlythis will never changecached hard — later visits may skip your server entirely
302 Foundtemporarynot cached, comes back to you every time
307/308same as 302/301 but keeps the HTTP methodused for non-GET requests
Watch out: Browsers cache a 301 aggressively, often with no expiry unless you send cache headers. If you send 301 and the user later deletes or edits that link, browsers that saw the first response may keep going to the old destination. Send 302 unless you are certain the link can never change. If you want click counts, you need 302 anyway: a cached 301 never reaches your server to be counted.

The handler

internal/transport/web/handler.go — add this method
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
}
http.Redirect sets the Location header and writes the status for you. Do not write those two by hand.

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.

internal/transport/web/handler.go — update Routes
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.

internal/service/shortener.go — add this
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
}
The comment about ctx is the lesson here. The server cancels a request’s context when the handler returns, so background work that uses it is cancelled almost at once. That is why `Incr` gets `context.WithoutCancel(ctx)`; a fresh context with its own timeout would also work.
Note: One goroutine per redirect is fine at small scale. Under a traffic spike it starts a goroutine per visitor, each holding a database connection or waiting for one. The sturdier version sends codes to a buffered channel that a single worker drains, so the goroutine count stays fixed. Build the simple one now; the pattern comes back in the concurrency topics.

Test it

terminal
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.dev
curl -sL localhost:8080/$CODE -o /dev/null -w '%{url_effective}\n' # -L follows it
curl -si localhost:8080/nope | head -1 # 404
The short version
  • A redirect is a 3xx status plus a Location header. http.Redirect writes 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 /links beats GET /{code}.
  • Do the click count in the background, and do not give that goroutine the request context.
Remember: The redirect is the hot path. Everything the visitor does not need should happen after the response, not before it.
Build this before moving on
Add the redirect and click counting. Open a short link in a real browser, then check the count went up. Then try it with a 301 and see it cached. You will need a private window to test it again.
Then try these in your own editor
  1. 1Return a proper HTML 404 page for a missing code instead of plain text.
  2. 2Replace the goroutine-per-redirect with one buffered channel and a single worker draining it.
  3. 3Log the referrer and user agent alongside each click, then decide whether you should be storing that.
  4. 4Measure how much slower the redirect gets if you count synchronously instead.
Check yourselfTake the Project 1 — URL shortener test