Build a Round-Robin Reverse Proxy in Go

Build a Go reverse proxy that routes traffic across local backends.

Introduction

30 Second Summary

One web address can hide a surprising amount of traffic management. Each incoming request still needs a clear destination when several servers are ready to answer.

In this project, you will build a local round-robin HTTP reverse proxy in Go. A small client will make each routing decision visible in its output.

What You'll Build

You will watch a burst of requests to one local address produce printed JSON responses that alternate between backend-a and backend-b.

By the end of this project, you'll have:

  • A repeatable demo that shows alternating responses from backend-a and backend-b through one proxy address.
  • Responses that preserve the /demo path plus forwarding details for the original request.
  • A visible upstream failure test where stopping backend-b produces an HTTP 502 response containing backend unavailable.
  • Secret Mission: Add active health checks so the proxy skips a stopped backend and restores it after recovery.

Are there any prerequisites?

This project assumes basic Go knowledge plus familiarity with HTTP requests on Windows. Your computer needs Go plus Visual Studio Code installed.

Before We Start

Before any hands-on work, lock in the local traffic path you are building: a reverse proxy on port 8080 distributes requests across backend-a on port 9001 plus backend-b on port 9002. Each JSON response identifies the backend that handled it.

Prepare the Go Module

Your reverse proxy will grow into three Go programs. A fixed toolchain keeps every program on the same language version.

A predictable folder layout gives each command one home. This step creates that layout inside a Go module.

In this step, get ready to:
  • Verify that Go 1.27.0 is installed.
  • Create the project folder structure in Visual Studio Code.
  • Initialize the example.com/go-load-balancer module.
Verify the Go version

The installed Go version determines which language features your programs can use. This project uses version 1.27.0 so every file behaves consistently.

  • Press the Windows key to open search.
  • Type Visual Studio Code into the search field.
  • Press Enter to open Visual Studio Code.
  • Select View in the menu bar.
  • Select Terminal to open the integrated terminal.
  • Check the installed Go version by running this command:
go version

What does this command show?

The command prints the Go release available to your terminal. The output needs to include go1.27.0 for this project.

✔️ I see Go 1.27.0

Your Go toolchain is ready. You can continue with the project structure.

ⓧ I see another version

  • Open the official Go downloads page in your browser.
  • Choose the Windows installer for Go 1.27.0 that matches your processor.
  • Run the downloaded installer.

Visual Studio Code needs a fresh terminal session before it can use the updated toolchain.

  • Close Visual Studio Code.
  • Press the Windows key to open search.
  • Type Visual Studio Code into the search field.
  • Press Enter to reopen Visual Studio Code.
  • Select View in the menu bar.
  • Select Terminal to open a fresh terminal.
  • Check the updated version by running:
go version

What confirms the update?

The new output should include go1.27.0. This confirms the fresh terminal can find the required toolchain.

Still seeing another version?

Restart Windows if the old version remains after reinstalling Go. This refreshes the system path used by new applications.

Select Get help updating Go on Windows.

ⓧ Command not found

  • Open the official Go downloads page in your browser.
  • Choose the Windows installer for Go 1.27.0 that matches your processor.
  • Run the downloaded installer.

The installer makes Go available to new terminal sessions.

  • Close Visual Studio Code.
  • Press the Windows key to open search.
  • Type Visual Studio Code into the search field.
  • Press Enter to reopen Visual Studio Code.
  • Select View in the menu bar.
  • Select Terminal to open a fresh terminal.
  • Verify the installation by running:
go version

What confirms the installation?

The output should include go1.27.0. Your terminal can now locate the Go toolchain.

Go still unavailable?

Restart Windows if a fresh terminal still cannot locate Go. This reloads the system path changed by the installer.

Select Get help finding Go from Visual Studio Code.

Create the project structure

A Visual Studio Code workspace anchors its integrated terminal to one folder. Opening go-load-balancer as the workspace keeps every command inside the project.

  • Select File in the menu bar.
  • Select Open Folder....
  • Select Desktop in the folder dialog.
  • Select New Folder.
  • Enter go-load-balancer as the folder name.
  • Select Select Folder to open the new folder.
  • Confirm that you trust the folder if Visual Studio Code asks.

You will see GO-LOAD-BALANCER at the top of the Explorer sidebar. This confirms that the project folder is open.

  • Select Terminal in the menu bar.
  • Select New Terminal.
  • Confirm that the terminal tab shows PowerShell.
  • Create the command folders by running these commands:
New-Item -Path ".\cmd" -ItemType "Directory"
New-Item -Path ".\cmd\backend", ".\cmd\client", ".\cmd\proxy" -ItemType "Directory"

What do these commands create?

The first command creates the cmd folder. This folder holds the runnable programs in your module.

The second command creates separate homes for the backend server, test client, and proxy.

  • Check the Explorer sidebar for the new folders.

You will see backend, client, and proxy nested inside cmd.

Don't see the command folders?

  • Use the terminal profile dropdown to select PowerShell if another shell is active.
  • Collapse the cmd folder in the Explorer sidebar.
  • Expand the cmd folder again.

Select Get help creating the project folders.

Initialize and verify the module

A Go module gives the project a stable import path. Its go.mod file also records the minimum toolchain version.

  • Initialize the module from the go-load-balancer folder by running:
go mod init example.com/go-load-balancer

What does this command create?

Go creates go.mod in the open project folder. The file identifies the module as example.com/go-load-balancer.

The version directive records Go 1.27.0 as the minimum toolchain for this module.

  • Select go.mod in the Explorer sidebar.

You will see the module path followed by the Go version directive.

✔️ Awesome, I've got everything!

Your generated go.mod matches the required module configuration.

ⓧ I'd like to double check the full code

module example.com/go-load-balancer

go 1.27.0

Before you run this final check, predict whether Go will point inside the go-load-balancer folder.

  • Confirm the active module path by running:
go env GOMOD

What does this command prove?

Go prints the absolute path to the active go.mod file. This confirms that the terminal is running from your project workspace.

You will see an absolute path ending in go-load-balancer\go.mod. Your module and folder structure are ready.

Seeing a different module path?

Confirm that GO-LOAD-BALANCER appears at the top of the Explorer sidebar. Create a new integrated terminal after the correct folder is open.

Select Get help locating the active Go module.

You now have a pinned Go module with a clear home for each program. Next, you will build two backends that identify themselves in every response.

Run Identifiable Backends

Your Go module now gives every command a predictable home. The next job is to create servers that show exactly which process handled a request.

Distinct backend identities create the visible signal that a load balancer needs. Each HTTP backend returns a JSON response containing its name. The response also includes the requested path.

In this step, get ready to:
  • Build a configurable backend that returns its identity as JSON.
  • Build a client that prints each response status and body.
  • Run two backend instances. Compare their identities.
Build the configurable backend

One backend program can represent several server instances when its name and address come from flags. The response fields make each instance observable from the client.

  • In the Explorer sidebar, right-click cmd/backend.
  • Select New File.
  • Enter main.go.
  • Press Enter.

You should see cmd/backend/main.go open in the editor.

  • Add the imports and response shape to cmd/backend/main.go by pasting this first block:
package main

import (
	"flag"
	"log"
	"net/http"
	"time"

	"encoding/json/v2"
)

type backendResponse struct {
	Backend        string `json:"backend"`
	Path           string `json:"path"`
	ForwardedFor   string `json:"forwarded_for"`
	ForwardedHost  string `json:"forwarded_host"`
	ForwardedProto string `json:"forwarded_proto"`
}

What does this code do?

  • The imports provide flag parsing, logging, HTTP handling, timing, and JSON encoding.
  • The backendResponse type defines the fields returned by every request.
  • The JSON tags set the field names that you see in the client output.
  • The forwarding fields prepare the backend to report proxy metadata in a later step.
  • Place the executable server one blank line below backendResponse by pasting this block:
func main() {
	name := flag.String("name", "backend", "name returned in responses")
	addr := flag.String("addr", ":9001", "HTTP listen address")
	flag.Parse()

	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")

		response := backendResponse{
			Backend:        *name,
			Path:           r.URL.Path,
			ForwardedFor:   r.Header.Get("X-Forwarded-For"),
			ForwardedHost:  r.Header.Get("X-Forwarded-Host"),
			ForwardedProto: r.Header.Get("X-Forwarded-Proto"),
		}

		if err := json.MarshalWrite(w, &response); err != nil {
			log.Printf("encode response: %v", err)
		}
	})

	server := &http.Server{
		Addr:              *addr,
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("%s listening on %s", *name, *addr)
	log.Fatal(server.ListenAndServe())
}

How does the backend work?

  • The name flag controls the identity returned in each response.
  • The addr flag controls the local address used by the server.
  • The ServeMux sends every path to the same handler.
  • The handler copies the request path and forwarding headers into backendResponse.
  • The http.Server applies the selected address and a header-reading timeout.

✔️ Awesome, I've got everything!

Your two code blocks now form one configurable backend server.

ⓧ I'd like to double check the full code

package main

import (
	"flag"
	"log"
	"net/http"
	"time"

	"encoding/json/v2"
)

type backendResponse struct {
	Backend        string `json:"backend"`
	Path           string `json:"path"`
	ForwardedFor   string `json:"forwarded_for"`
	ForwardedHost  string `json:"forwarded_host"`
	ForwardedProto string `json:"forwarded_proto"`
}

func main() {
	name := flag.String("name", "backend", "name returned in responses")
	addr := flag.String("addr", ":9001", "HTTP listen address")
	flag.Parse()

	mux := http.NewServeMux()
	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")

		response := backendResponse{
			Backend:        *name,
			Path:           r.URL.Path,
			ForwardedFor:   r.Header.Get("X-Forwarded-For"),
			ForwardedHost:  r.Header.Get("X-Forwarded-Host"),
			ForwardedProto: r.Header.Get("X-Forwarded-Proto"),
		}

		if err := json.MarshalWrite(w, &response); err != nil {
			log.Printf("encode response: %v", err)
		}
	})

	server := &http.Server{
		Addr:              *addr,
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("%s listening on %s", *name, *addr)
	log.Fatal(server.ListenAndServe())
}
  • Save cmd/backend/main.go.

The backend remains attached to its terminal while it serves requests. Leave this process running because the client needs a live server.

Before you start the server, predict which name and address its first log line will contain:

  • Start backend-a from the integrated terminal by running this command:
go run ./cmd/backend -name backend-a -addr :9001

What does this command do?

This command compiles the backend package. It passes backend-a as the instance name.

The address :9001 makes this instance listen on local port 9001.

You should see a log line ending with backend-a listening on :9001. The terminal remains busy while backend-a listens for requests.

Backend-a did not start?

  • Confirm that cmd/backend/main.go matches the full-code tab.
  • Confirm that the terminal is inside the go-load-balancer folder.
  • Check whether another local program is already using port 9001.

Help me diagnose why my Go backend does not start on port 9001.

Build the request client

A reusable client turns server behavior into terminal output. It sends repeated requests through one http.Client so you can compare the status and body from each backend.

  • In the Explorer sidebar, right-click cmd/client.
  • Select New File.
  • Enter main.go.
  • Press Enter.

You should see cmd/client/main.go open beside the backend file.

  • Add the client imports and configuration to cmd/client/main.go by pasting this first block:
package main

import (
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"time"
)

func main() {
	target := flag.String("url", "http://localhost:8080", "URL to request")
	count := flag.Int("count", 6, "number of requests")
	flag.Parse()

	client := &http.Client{Timeout: 2 * time.Second}

What does this client setup do?

  • The url flag selects the request destination.
  • The count flag controls how many requests the client sends.
  • The shared http.Client is reused across the request loop.
  • The timeout prevents one request from waiting indefinitely.
  • Place the request loop one blank line below the client configuration by pasting this block:
for requestNumber := 1; requestNumber <= *count; requestNumber++ {
		response, err := client.Get(*target)
		if err != nil {
			log.Printf("request %d failed: %v", requestNumber, err)
			continue
		}

		body, readErr := io.ReadAll(response.Body)
		response.Body.Close()
		if readErr != nil {
			log.Printf("request %d body read failed: %v", requestNumber, readErr)
			continue
		}

		fmt.Printf("request %d: status=%d body=%s\n", requestNumber, response.StatusCode, body)
	}
}

How does the request loop work?

  • The loop sends the requested number of GET requests.
  • Each successful request provides a response status and body.
  • The client reads the complete body before closing it.
  • The final output joins the request number, status code, and JSON body on one line.

✔️ Awesome, I've got everything!

Your two client chunks now form one reusable request program.

ⓧ I'd like to double check the full code

package main

import (
	"flag"
	"fmt"
	"io"
	"log"
	"net/http"
	"time"
)

func main() {
	target := flag.String("url", "http://localhost:8080", "URL to request")
	count := flag.Int("count", 6, "number of requests")
	flag.Parse()

	client := &http.Client{Timeout: 2 * time.Second}

	for requestNumber := 1; requestNumber <= *count; requestNumber++ {
		response, err := client.Get(*target)
		if err != nil {
			log.Printf("request %d failed: %v", requestNumber, err)
			continue
		}

		body, readErr := io.ReadAll(response.Body)
		response.Body.Close()
		if readErr != nil {
			log.Printf("request %d body read failed: %v", requestNumber, readErr)
			continue
		}

		fmt.Printf("request %d: status=%d body=%s\n", requestNumber, response.StatusCode, body)
	}
}
  • Save cmd/client/main.go.
  • Create another integrated terminal by selecting the + icon at the top-right of the terminal panel.

Before you send the first request, predict which backend name and path the response body will contain:

  • Send one request directly to backend-a by running this command:
go run ./cmd/client -url http://localhost:9001/demo -count 1

What does this command check?

The client sends one request to the /demo path on backend-a. It prints the HTTP status and the backend's JSON response.

You should see status=200 in the output. The JSON body should contain "backend":"backend-a" and "path":"/demo".

Client request failed?

  • Confirm that the backend-a terminal still shows a running process.
  • Confirm that backend-a is listening on :9001.
  • Compare cmd/client/main.go with the full-code tab if the client does not compile.

Help me troubleshoot why my Go client cannot request backend-a on port 9001.

Run both backend instances

Two independent processes can run the same backend program with different flags. Their distinct names prove that requests can reach separate server instances.

The second backend also stays attached to its terminal while it listens. Keep both backend terminals running for the comparison.

  • Create another integrated terminal by selecting the + icon at the top-right of the terminal panel.
  • Start backend-b in the new terminal by running this command:
go run ./cmd/backend -name backend-b -addr :9002

What changes for backend-b?

This process uses the same backend code. Its flags give it the identity backend-b and the local address :9002.

You should see a log line ending with backend-b listening on :9002.

Before you send the next request, predict which field will change while the path stays the same:

  • Return to the client terminal by selecting it from the terminal list.
  • Press the Up Arrow key to recall the previous client command.
  • Replace 9001 with 9002.
  • Press Enter to send the request.

You should see status=200 with "backend":"backend-b" in the body. The response should still contain "path":"/demo".

Why are the forwarding fields empty?

These requests connect directly to each backend. The X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto values therefore appear as empty strings.

A later proxy request supplies those fields. Their presence in the current JSON gives you a baseline for that comparison.

Backend-b is missing from the output?

  • Confirm that the backend-b terminal still shows a running process.
  • Confirm that its listening address is :9002.
  • Check that the recalled client command now targets port 9002.

Help me find why my client does not show backend-b from port 9002.

You now have two live backends that identify themselves while preserving the requested path. Next, you will place a reverse proxy in front of them and make one public endpoint forward requests.

Put a Proxy in Front

Your two backend servers now answer direct requests on separate ports. Callers still need one address for reaching them.

In this step, you will place a reverse proxy on port 8080. The fixed target lets you test request forwarding before you measure distribution.

In this step, get ready to:
  • Create cmd/proxy/main.go with a validated backend URL.
  • Configure httputil.ReverseProxy to rewrite requests for backend-a.
  • Test the fixed routing decision with four client requests.
Parse the backend target

A proxy needs a parsed backend URL before it can rewrite an outbound request. The mustParseURL helper stops startup when the configured address is invalid.

  • In the Explorer sidebar of Visual Studio Code, select the cmd/proxy folder.
  • Create main.go inside cmd/proxy with the file creation control at the top of the sidebar.
  • Confirm main.go appears beneath cmd/proxy.
  • Add the package declaration, imports, and URL helper by pasting this code into cmd/proxy/main.go:
package main

import (
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"time"
)

func mustParseURL(rawURL string) *url.URL {
	target, err := url.Parse(rawURL)
	if err != nil {
		log.Fatalf("parse backend URL %q: %v", rawURL, err)
	}
	return target
}

What Does This Code Do?

  • The imports provide HTTP server, proxy, URL, logging, and timeout tools.
  • The url.Parse call converts the backend address into a URL that SetURL can use.
  • The log.Fatalf call ends startup when the backend URL cannot be parsed.
  • Save cmd/proxy/main.go.
  • Confirm the editor shows mustParseURL beneath the import group.

Is the Proxy File in the Wrong Place?

  • Check that main.go sits inside cmd/proxy.
  • Move the file into cmd/proxy if it appears beside the folder.
  • Compare the spelling of mustParseURL with the code above.

Help me fix the location or URL parser in my Go proxy file.

Forward requests to backend-a

The httputil.ReverseProxy receives each incoming request through Rewrite. The fixed target points every outbound request at backend-a.

  • Add main below mustParseURL by pasting this code:
func main() {
	target := mustParseURL("http://localhost:9001")

	proxy := &httputil.ReverseProxy{
		Rewrite: func(request *httputil.ProxyRequest) {
			request.SetURL(target)
			request.SetXForwarded()
			log.Printf("routing %s %s to %s", request.In.Method, request.In.URL.Path, target)
		},
	}

	server := &http.Server{
		Addr:              ":8080",
		Handler:           proxy,
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("load balancer listening on %s", server.Addr)
	log.Fatal(server.ListenAndServe())
}

What Does This Code Do?

  • The target value stores the parsed URL for backend-a.
  • The Rewrite function runs for every incoming request. SetURL changes the outbound destination while preserving the incoming path.
  • The SetXForwarded call adds the original client, host, and protocol metadata.
  • The http.Server value listens on :8080 with the proxy as its handler.

Why Use ReverseProxy?

A hand-written HTTP client would require manual request copying plus manual response copying. ReverseProxy keeps this project focused on choosing a target and preserving proxy metadata.

This proxy is a long-running server. Its terminal stays busy until you stop it with Ctrl+C.

  • Save cmd/proxy/main.go.
  • Return to backend-a's terminal from earlier.
  • Confirm backend-a still shows it listening on :9001.
  • Switch to the available integrated terminal at the go-load-balancer project root.
  • Start the proxy by running this command:
go run ./cmd/proxy

What Should I See?

You should see load balancer listening on :8080 in the proxy terminal. The process remains active while it waits for requests.

Proxy Does Not Start?

  • Stop any earlier process using port 8080 before trying again.
  • Press Ctrl+C in the earlier process's terminal to stop it.
  • Compare the imports and braces in cmd/proxy/main.go with the full code below if Go reports a compile error.

Help me diagnose why my Go proxy does not start on port 8080.

✔️ Awesome, I've got everything!

Your saved cmd/proxy/main.go now contains the backend target, rewrite logic, and HTTP server.

ⓧ I'd like to double check the full code

package main

import (
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"time"
)

func mustParseURL(rawURL string) *url.URL {
	target, err := url.Parse(rawURL)
	if err != nil {
		log.Fatalf("parse backend URL %q: %v", rawURL, err)
	}
	return target
}

func main() {
	target := mustParseURL("http://localhost:9001")

	proxy := &httputil.ReverseProxy{
		Rewrite: func(request *httputil.ProxyRequest) {
			request.SetURL(target)
			request.SetXForwarded()
			log.Printf("routing %s %s to %s", request.In.Method, request.In.URL.Path, target)
		},
	}

	server := &http.Server{
		Addr:              ":8080",
		Handler:           proxy,
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("load balancer listening on %s", server.Addr)
	log.Fatal(server.ListenAndServe())
}

What Should Match?

This reference contains the complete cmd/proxy/main.go state for this step. It includes the package declaration, imports, URL helper, rewrite function, and server configuration.

Test the fixed routing decision

A repeated client run reveals the routing decision behind the single proxy address. Four requests create enough evidence to spot a pattern.

  • Create one more integrated terminal with the terminal creation control in the Terminal panel.
  • Confirm the new terminal starts at the go-load-balancer project root.
  • Before you run the client, predict whether four requests reach one backend or both backends.
  • Send four /demo requests through port 8080 by running this command:
go run ./cmd/client -url http://localhost:8080/demo -count 4

What Should I See?

  • You should see four status=200 responses.
  • Every JSON body should contain "backend":"backend-a".
  • Every body should contain "path":"/demo".
  • The forwarded_for, forwarded_host, and forwarded_proto fields should contain the metadata added by the proxy.

The repeated backend-a result is the intended shortfall. A fixed target forwards requests without distributing them.

  • Return to the proxy terminal from earlier.
  • Compare its four routing log lines with the client output.

You should see /demo plus http://localhost:9001 in every routing line.

Good result. Your single public address now preserves the request path plus forwarding metadata.

Do the Four Requests Fail?

  • Confirm backend-a's terminal still shows it listening on :9001.
  • Confirm the proxy terminal still shows it listening on :8080.
  • Check that the client target is http://localhost:8080/demo.

Help me troubleshoot failed client requests through my proxy.

Your public endpoint now preserves the path plus forwarding metadata. Next, you will replace the fixed target with concurrency-safe round-robin selection.

Add Round-Robin Routing

Your Go reverse proxy now preserves the request path plus forwarding metadata. However, one fixed target sends every request to backend-a.

This step adds round-robin selection across both backends. The selector protects its shared index with sync.Mutex so concurrent requests cannot overlap their updates.

In this step, get ready to:
  • Define a synchronized pool for both backend URLs.
  • Select the next backend for every request.
  • Verify distribution across six client requests.
Define the Synchronized Pool

The pool needs three pieces of state: the available backend URLs, the next index, plus a lock. The lock keeps each selection isolated when multiple HTTP handlers reach the pool at the same time.

  • In the Explorer sidebar, return to cmd/proxy/main.go.
  • Find "net/url" in the import block.
  • Add the synchronization package directly below that line by pasting:
"sync"

Why Add This Import?

The sync package provides the mutex used by the pool. That mutex protects the shared routing index.

  • Add the pool definition below the import block by pasting this code:
type roundRobin struct {
	mu       sync.Mutex
	backends []*url.URL
	index    int
}

What Does This Type Store?

  • The mu field protects routing state during concurrent requests.
  • The backends slice stores the available upstream URLs.
  • The index field points to the backend selected next.
  • Save cmd/proxy/main.go.
  • Confirm every package still compiles by running this command in the integrated terminal:
go test ./...

What Does This Check Prove?

This command compiles every package in the module. A successful result proves the new import plus pool type are valid Go code.

You should see each package complete without a build failure. Your proxy now has a concurrency-safe place to store routing state.

Seeing a Build Failure?

  • Check that "sync" sits inside the existing import block.
  • Check that roundRobin appears below the closing parenthesis of the import block.
  • Compare the field names with mu, backends, plus index.

Help me fix the new roundRobin type.

Select the Next Backend

The pool stores the routing choices. The nextBackend method turns its current index into one deterministic selection per request.

  • Add the selector directly below the roundRobin type by pasting this method:
func (r *roundRobin) nextBackend() *url.URL {
	r.mu.Lock()
	defer r.mu.Unlock()

	target := r.backends[r.index]
	r.index = (r.index + 1) % len(r.backends)
	return target
}

How Does Selection Work?

  • The lock gives one request exclusive access to the shared index.
  • The current index selects one URL from backends.
  • The modulo calculation advances the index. It wraps back to zero after the final backend.
  • The deferred unlock releases the routing state before the method returns.
  • Save cmd/proxy/main.go.
  • Check the selector for compilation errors by running:
go test ./...

What Does This Check Prove?

This compilation check confirms that the selector can lock the pool, read its URL slice, plus advance the index. The method is ready to drive each proxy request.

You should see every package complete successfully. The pool can now rotate through its URLs safely.

Selector Not Compiling?

  • Check that the method receiver uses *roundRobin so index updates affect the shared pool.
  • Check that the modulo expression uses len(r.backends).
  • Check that the method returns target after advancing the index.

Help me debug nextBackend.

The selector now needs both backend URLs plus one call from every proxy rewrite. Replacing main wires those pieces into the existing server.

  • Select the existing main function in cmd/proxy/main.go.
  • Replace the selected function with this updated version:
func main() {
	pool := &roundRobin{
		backends: []*url.URL{
			mustParseURL("http://localhost:9001"),
			mustParseURL("http://localhost:9002"),
		},
	}

	proxy := &httputil.ReverseProxy{
		Rewrite: func(request *httputil.ProxyRequest) {
			target := pool.nextBackend()
			request.SetURL(target)
			request.SetXForwarded()
			log.Printf("routing %s %s to %s", request.In.Method, request.In.URL.Path, target)
		},
	}

	server := &http.Server{
		Addr:              ":8080",
		Handler:           proxy,
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("load balancer listening on %s", server.Addr)
	log.Fatal(server.ListenAndServe())
}

How Does the Proxy Use the Pool?

  • The pool contains the URLs for backend-a plus backend-b.
  • Each Rewrite call asks nextBackend for one target.
  • The SetURL call routes the outbound request to that selected target.
  • The SetXForwarded call preserves the original client context.
  • The routing log records the selected target for comparison with the client output.
  • Save cmd/proxy/main.go.
  • Confirm the completed routing code compiles by running:
go test ./...

What Does This Check Prove?

This final compilation check covers the pool, selector, proxy rewrite, plus server wiring. A clean result means the updated proxy is ready to restart.

You should see all packages complete without a build failure. That is the hard part done: your routing code is ready to serve requests.

Proxy Package Not Compiling?

  • Check that the old fixed target declaration disappeared with the previous main function.
  • Check that pool.nextBackend() appears inside Rewrite.
  • Check that both backend URLs remain inside the backends slice.

Help me compile the updated proxy.

✔️ Awesome, I've got everything!

Great. Double-check that you saved cmd/proxy/main.go before restarting the proxy.

ⓧ I'd like to double check the full code

package main

import (
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"sync"
	"time"
)

type roundRobin struct {
	mu       sync.Mutex
	backends []*url.URL
	index    int
}

func (r *roundRobin) nextBackend() *url.URL {
	r.mu.Lock()
	defer r.mu.Unlock()

	target := r.backends[r.index]
	r.index = (r.index + 1) % len(r.backends)
	return target
}

func mustParseURL(rawURL string) *url.URL {
	target, err := url.Parse(rawURL)
	if err != nil {
		log.Fatalf("parse backend URL %q: %v", rawURL, err)
	}
	return target
}

func main() {
	pool := &roundRobin{
		backends: []*url.URL{
			mustParseURL("http://localhost:9001"),
			mustParseURL("http://localhost:9002"),
		},
	}

	proxy := &httputil.ReverseProxy{
		Rewrite: func(request *httputil.ProxyRequest) {
			target := pool.nextBackend()
			request.SetURL(target)
			request.SetXForwarded()
			log.Printf("routing %s %s to %s", request.In.Method, request.In.URL.Path, target)
		},
	}

	server := &http.Server{
		Addr:              ":8080",
		Handler:           proxy,
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("load balancer listening on %s", server.Addr)
	log.Fatal(server.ListenAndServe())
}

What Should You Compare?

Check the sync import, the roundRobin type, the nextBackend method, plus the two URLs in pool. The Rewrite function should select a fresh target for every request.

Restart and Verify the Proxy

The running proxy still uses the previous process image. Restarting it loads the new selector while both backend terminals stay active.

  • Switch back to the integrated terminal for backend-b.
  • Rerun the backend-b command from earlier if that process is stopped.

You should see backend-b listening on :9002 when backend-b is ready.

  • Switch back to the proxy terminal from earlier.
  • Press Ctrl+C to stop the old proxy process.
  • Press the Up Arrow key to recall the previous proxy command.
  • Press Enter to restart the proxy.

You should see load balancer listening on :8080. The restarted proxy is now accepting requests with the round-robin pool.

  • Predict which backend will handle each of the six numbered requests.
  • Send six requests through the restarted proxy by running:
go run ./cmd/client -url http://localhost:8080/demo -count 6

What Should You See?

  • Odd-numbered requests should report "backend":"backend-a".
  • Even-numbered requests should report "backend":"backend-b".
  • Every response should show status=200 plus "path":"/demo".
  • The proxy terminal should alternate between http://localhost:9001 plus http://localhost:9002 in its routing logs.
  • Compare each client request number with its corresponding routing line in the proxy terminal.

The client responses now alternate between backend-a plus backend-b. Your single endpoint distributes requests across both running servers.

Responses Not Alternating?

  • Check that backend-b still shows a running process on :9002.
  • Check that you restarted the proxy after saving cmd/proxy/main.go.
  • Check that the pool contains both localhost backend URLs.

Help me trace the routing sequence.

Your proxy now distributes traffic safely across both backends. Next, you will make an unreachable backend produce a clear response.

Expose Upstream Failures

Your proxy now uses round-robin routing to alternate requests between backend-a and backend-b. Its logs show which upstream receives each request.

A selected backend can still disappear after the proxy chooses it. This step gives your reverse proxy an explicit HTTP 502 response for that failure.

In this step, get ready to:
  • Add an ErrorHandler that converts upstream connection failures into explicit client responses.
  • Stop backend-b to expose a failed round-robin selection.
  • Restart backend-b to prove normal routing recovers.
Add an explicit proxy error response

The ErrorHandler field gives the reverse proxy control over connection failures. It keeps the client response consistent while detailed errors stay in the proxy log.

  • In cmd/proxy/main.go, find the proxy := &httputil.ReverseProxy{ block.
  • Add the following ErrorHandler below the existing Rewrite function:
		ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
			log.Printf("backend request failed for %s: %v", r.URL.Path, err)
			http.Error(w, "backend unavailable", http.StatusBadGateway)
		},

What does this code do?

  • The ErrorHandler function runs when the proxy cannot reach the selected backend.
  • The log.Printf call records the request path plus the Go error in the proxy terminal.
  • The http.Error call returns backend unavailable with http.StatusBadGateway.
  • Save cmd/proxy/main.go.
  • Switch back to the proxy terminal from earlier.
  • Stop the running proxy process with Ctrl+C.
  • Recall the previous proxy command with the Up Arrow key.
  • Press Enter to restart the proxy.

You will see load balancer listening on :8080 in the proxy terminal.

Proxy Won't Restart?

  • Check that ErrorHandler sits inside the httputil.ReverseProxy literal after Rewrite.
  • Confirm the comma after the handler's closing brace remains in place.
  • Confirm the earlier proxy process is fully stopped before restarting it.

Help me fix my proxy after adding ErrorHandler.

Observe alternating success and failure

Stopping backend-b creates a real upstream outage without changing the round-robin selector. Every request still advances the shared index.

  • Switch back to the integrated terminal running backend-b.
  • Stop the backend-b process with Ctrl+C.
  • Switch back to the client terminal from earlier.
  • Predict whether every request will fail while only backend-b is stopped.
  • Test four routed requests by running:
go run ./cmd/client -url http://localhost:8080/demo -count 4

What Should I See?

  • A status=200 result identifies a request handled by backend-a.
  • A status=502 body=backend unavailable result identifies a selection that reached the stopped backend-b.
  • The alternating results prove that round-robin selection continues during the outage.
  • Switch back to the proxy terminal.
  • Match each status=502 response to a logged routing decision for http://localhost:9002.
  • Return to the client terminal so its four-request output is visible.

You made the outage legible. The client now receives a deliberate failure response when the selected upstream is unavailable.

Restore backend-b and verify recovery

The error handler covers outages without changing backend selection. Restarting backend-b confirms that normal traffic returns as soon as the process is available.

  • Switch back to the terminal assigned to backend-b.
  • Restart backend-b by running:
go run ./cmd/backend -name backend-b -addr :9002

What Should I See?

You will see backend-b listening on :9002 in its terminal. The process is ready to receive proxy traffic again.

Backend-b Won't Restart?

  • Confirm the previous backend-b process stopped before you ran the command.
  • Check that the command still uses :9002 for the listen address.

Help me restart backend-b on port 9002.

  • Predict the response pattern now that both backends are available.
  • Switch back to the client terminal.
  • Verify recovered round-robin routing by running:
go run ./cmd/client -url http://localhost:8080/demo -count 4

What Should I See?

  • All four responses show status=200.
  • The JSON bodies alternate between backend-a and backend-b.
  • Each JSON body reports "path":"/demo".
  • The populated forwarded_for, forwarded_host, and forwarded_proto fields prove that X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto still reach each backend.

Still Seeing 502 Responses?

  • Make sure the backend-b terminal is still running after it prints its listening message.
  • Check the proxy terminal for routing decisions that target http://localhost:9002.
  • Confirm the client command targets the proxy on http://localhost:8080/demo.

Help me trace continued 502 responses after restarting backend-b.

That is the recovery path proven. Your proxy alternates normally again after backend-b returns.

✔️ Awesome, I've got everything!

Your saved cmd/proxy/main.go now includes explicit upstream failure handling.

ⓧ I'd like to double check the full code

The complete proxy file is shown below for comparison.

package main

import (
	"log"
	"net/http"
	"net/http/httputil"
	"net/url"
	"sync"
	"time"
)

type roundRobin struct {
	mu       sync.Mutex
	backends []*url.URL
	index    int
}

func (r *roundRobin) nextBackend() *url.URL {
	r.mu.Lock()
	defer r.mu.Unlock()

	target := r.backends[r.index]
	r.index = (r.index + 1) % len(r.backends)
	return target
}

func mustParseURL(rawURL string) *url.URL {
	target, err := url.Parse(rawURL)
	if err != nil {
		log.Fatalf("parse backend URL %q: %v", rawURL, err)
	}
	return target
}

func main() {
	pool := &roundRobin{
		backends: []*url.URL{
			mustParseURL("http://localhost:9001"),
			mustParseURL("http://localhost:9002"),
		},
	}

	proxy := &httputil.ReverseProxy{
		Rewrite: func(request *httputil.ProxyRequest) {
			target := pool.nextBackend()
			request.SetURL(target)
			request.SetXForwarded()
			log.Printf("routing %s %s to %s", request.In.Method, request.In.URL.Path, target)
		},
		ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
			log.Printf("backend request failed for %s: %v", r.URL.Path, err)
			http.Error(w, "backend unavailable", http.StatusBadGateway)
		},
	}

	server := &http.Server{
		Addr:              ":8080",
		Handler:           proxy,
		ReadHeaderTimeout: 5 * time.Second,
	}

	log.Printf("load balancer listening on %s", server.Addr)
	log.Fatal(server.ListenAndServe())
}

Secret mission

Skip Unhealthy Backends

A clear 502 response explains an upstream failure, but normal rotation still sends traffic toward the stopped backend. In this mission, you will add active health checks that skip unavailable backends and restore them after recovery.

Clean Up Your Resources

Clean Up Your Resources

Everything you built runs on your Windows computer through local Go processes. There are no cloud services or ongoing costs.

You can keep the processes running, pause them for later, or delete the local project entirely.

Resources you used:

  • The backend-a process listening locally on :9001.
  • The backend-b process listening locally on :9002.
  • The health-aware reverse proxy process listening locally on :8080.
  • The go-load-balancer folder containing your Go module and source files.

Keep everything running

No action is needed. Choose this option if you are still testing routing or health recovery.

  • Leave the backend-a process running.
  • Leave the backend-b process running.
  • Leave the health-aware proxy process running.
  • Keep Visual Studio Code open with the go-load-balancer folder loaded.

Pause - I'll come back to this later

Pausing closes the three running processes to free their ports. Your source files stay inside go-load-balancer for your next session.

  • Press Ctrl+C in the integrated terminal running backend-a.
  • Press Ctrl+C in the integrated terminal running backend-b.
  • Press Ctrl+C in the integrated terminal running the health-aware proxy.

Each integrated terminal should return to a prompt. Your three ports are now free while the go-load-balancer files remain ready for another session.

Delete - I don't want to use this again

Deleting go-load-balancer removes every project file. Your Go installation stays available for future projects.

  • Press Ctrl+C in the integrated terminal running backend-a.
  • Press Ctrl+C in the integrated terminal running backend-b.
  • Press Ctrl+C in the integrated terminal running the health-aware proxy.

Each integrated terminal should return to a prompt before you remove the folder.

  • Move above the project folder and delete go-load-balancer by running these PowerShell commands:
cd ..
Remove-Item -Recurse -Force .\go-load-balancer

What do these commands do?

The first command moves the terminal to the directory containing your project folder. The second command permanently removes the folder and everything inside it.

  • Check the Explorer sidebar in Visual Studio Code.

You should no longer see the files from go-load-balancer. That completes the local cleanup without affecting your Go installation.

Folder Still Listed?

  • Confirm that all three integrated terminals have returned to a prompt before running the deletion commands again.
  • Help me remove a local Windows project folder that is still in use.

Nice Work!

Nice Work!

You made it! Your health-aware Go reverse proxy now balances local traffic across two backends. It keeps healthy requests moving when one backend stops.

What you learned:

  • Run two identifiable backend processes in Go. Use a reusable client to see which backend handled each request.
  • Forward paths through httputil.ReverseProxy. Preserve client context with Rewrite plus X-Forwarded-For, X-Forwarded-Host, and X-Forwarded-Proto.
  • Protect round-robin selection with sync.Mutex. Return a clear HTTP 502 when a selected upstream is unavailable.
  • Secret Mission: Extended the backend pool with active health checks that skip stopped backends. Recovered backends rejoin the round-robin rotation automatically.

Ready to quiz yourself?