Build a Site Health Checker in Go

Build a concurrent CLI tool in Go that checks multiple URLs simultaneously.

Introduction

30 Second Summary

When a site you depend on goes down, you want to know immediately. Whether you're monitoring your own services or checking third-party APIs before a deployment, speed matters.

In this project, you will build a command-line site health checker in Go that fires off concurrent HTTP requests and streams results back as they arrive.

What You'll Build

You run a single command with a list of URLs, and within seconds a results table shows which sites are up, which are down, their response times, and any errors.

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

  • A compiled single binary you can run anywhere without a runtime. Pass any list of URLs and instantly see their health status, status code, and response time.
  • Results that stream in concurrently as they arrive, powered by goroutines and channels. One slow site never blocks the others.
  • A summary report after every run showing total checked, successful, failed, and average response time.
  • Secret Mission: Add a configurable concurrency limiter using a semaphore channel pattern so you can control how many requests run at once.

Are there any prerequisites?

You need programming experience in any language (PHP, JavaScript, Python, etc.). No prior Go knowledge is required.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

You're building a concurrent site health checker CLI in Go. This project teaches you Go's concurrency model (goroutines + channels), its explicit error handling, and the power of compiling to a single binary with zero dependencies.

Install Go and Scaffold the Project

To build a site health checker, you first need the Go toolchain installed on your machine. Go compiles your code into a single binary with no runtime dependencies.

In this step, you will install Go, create a project folder, and run your first Go program to confirm everything works.

In this step, get ready to:
  • Install Go 1.26.5 on macOS.
  • Create the project folder and initialize a Go module.
  • Run the program to confirm your environment works.
Install Go 1.26.5

Go is available as a macOS installer package that places everything at /usr/local/go and adds the go command to your PATH.

  • Press Cmd+Space to open Spotlight.
  • Type Terminal and press Enter to open it.

Check whether Go is already installed by running this command:

go version

✔️ I see go1.26.5 or higher

You already have the right version installed. Skip ahead to the next substep.

ⓧ I see an older version

The macOS pkg installer will replace your existing version. Follow these steps to upgrade:

  • Open https://go.dev/dl/ in your browser.
  • Download go1.26.5.darwin-arm64.pkg (listed under "Apple macOS (ARM64)").
  • Double-click the downloaded .pkg file and follow the installer prompts.
  • Close your current Terminal window and open a new one so the updated PATH takes effect.

Verify the upgrade by running:

go version

You should see go version go1.26.5 darwin/arm64 printed.

ⓧ Command not found

Go is not installed yet. Follow these steps to install it:

  • Open https://go.dev/dl/ in your browser.
  • Download go1.26.5.darwin-arm64.pkg (listed under "Apple macOS (ARM64)").
  • Double-click the downloaded .pkg file and follow the installer prompts.
  • Close your current Terminal window and open a new one so the PATH is loaded fresh.

What if go version still says command not found?

The installer adds /usr/local/go/bin to your PATH. If it did not take effect, add it manually by running:

export PATH=$PATH:/usr/local/go/bin

To make this permanent, add that same line to your ~/.zshrc file.

Verify the installation by running:

go version

You should see go version go1.26.5 darwin/arm64 printed.

Create the project directory and initialize a module

Every Go project lives inside a module. A module is a folder with a go.mod file that tracks the project's name and dependencies.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir sitechecker
cd sitechecker
  • Initialize a Go module by running this command:
go mod init sitechecker

You should see a message confirming the module was created.

What does go mod init do?

This command creates a go.mod file in your folder. The file declares your module's name (sitechecker) and the Go version it uses. Go uses this file to manage dependencies as your project grows.

  • Confirm the file exists by running:
ls

You should see go.mod listed in the output.

Now create the main program file. Open sitechecker in your code editor and create a new file called main.go.

  • Paste the following code into main.go:
package main

import "fmt"

func main() {
	fmt.Println("Site Health Checker starting...")
}

What does this code do?

Every Go program starts with package main. This tells Go that this file is the entry point of a runnable program, not a library.

The import "fmt" line brings in Go's formatting package for printing text. The func main() function is where execution begins when you run the program.

  • Save main.go.
Run the program to verify your setup

Before you run this, what do you expect to see printed in your terminal?

  • Run your program by executing this command in Terminal:
go run main.go

You should see Site Health Checker starting... printed in your terminal. That confirms Go is installed, your module is set up, and your code runs.

What does go run do?

The go run command compiles your Go source file into a temporary binary and immediately executes it. It is a shortcut for development. Later, you will use go build to produce a permanent compiled binary.

Not seeing the output?

  • Make sure you saved main.go before running the command.
  • Check that your terminal is inside the sitechecker folder. Run pwd to confirm.
  • If you see an error about go: cannot find main module, make sure go.mod exists in the same folder as main.go.

Still stuck? Help me debug why go run main.go is not printing output.

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and move on to the next step.

ⓧ I'd like to double check the full code

Here is what your main.go should look like:

package main

import "fmt"

func main() {
	fmt.Println("Site Health Checker starting...")
}

And your go.mod (auto-generated by go mod init) should look like:

module sitechecker

go 1.26.5

Your environment is ready and your first Go program runs. Next up, you will make an HTTP request to a real website and measure its response time.

Check a Single URL and Print the Result

Your Go environment is ready and confirmed working. Now it is time to make the tool actually do something useful.

Before tackling concurrency, you need to solve the simpler problem first: how do you make a single HTTP request in Go, measure how long it takes, and report the result? This step builds that single-URL checker. Once it works, you will have the building block that the concurrent version scales up.

In this step, get ready to:
  • Define a Result struct to hold health check outcomes.
  • Write a checkURL function that makes a timed HTTP GET request.
  • Run the program and see a live status report for a real URL.
Define the Result struct and checkURL function

Every URL check produces a handful of data points: the URL itself, the HTTP status code, how long the request took, and whether an error occurred. In Go, you bundle related data into a struct.

  • In main.go, replace the existing import "fmt" line with the following import block and struct definition:
import (
	"fmt"
	"net/http"
	"time"
)

// Result holds the outcome of checking a single URL
type Result struct {
	URL        string
	StatusCode int
	Duration   time.Duration
	Err        error
}

What does this code do?

The import block pulls in three standard library packages: fmt for printing, net/http for making HTTP requests, and time for measuring durations.

The Result struct groups four fields together so you can pass a single value that contains everything you need to know about a check. Go structs are similar to classes in other languages, but without methods attached by default.

  • Now add the checkURL function below the struct, above the existing func main():
// checkURL makes a GET request and measures response time
func checkURL(url string, client *http.Client) Result {
	start := time.Now()
	resp, err := client.Get(url)
	duration := time.Since(start)

	if err != nil {
		return Result{URL: url, Err: err, Duration: duration}
	}
	defer resp.Body.Close()

	return Result{
		URL:        url,
		StatusCode: resp.StatusCode,
		Duration:   duration,
	}
}

What does checkURL do?

  • time.Now() captures the moment before the request. time.Since(start) calculates the elapsed time after the request completes.
  • client.Get(url) returns two values: a response and an error. Go has no try/catch. Instead, you check if err != nil after every operation that can fail.
  • defer resp.Body.Close() schedules the response body to be closed when the function exits. This frees up the underlying TCP connection for reuse.
Call checkURL from main and run the program

The struct and function are ready. Now you need main() to create an HTTP client, call the function, and print the result.

  • Replace the entire existing func main() block (including the fmt.Println line) with this:
func main() {
	// Create an HTTP client with a 10-second timeout
	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	// Check a single URL and print the result
	result := checkURL("https://go.dev", client)
	if result.Err != nil {
		fmt.Printf("%s - Error: %s (%dms)\n", result.URL, result.Err, result.Duration.Milliseconds())
	} else {
		fmt.Printf("%s - %d OK - %dms\n", result.URL, result.StatusCode, result.Duration.Milliseconds())
	}
}

What does this main function do?

  • &http.Client{Timeout: 10 * time.Second} creates a shared HTTP client. The timeout covers the entire request lifecycle: DNS lookup, connection, and reading the response body.
  • The if/else block handles both outcomes: a network error (like a DNS failure) and a successful response (even if the status code indicates a server error like 500).
  • Duration.Milliseconds() converts the time.Duration value to an integer for clean output.

Before you run this, what do you think the output will look like? Will the request succeed or fail?

  • Save main.go and run the program:
go run main.go

You should see output like this (your response time will vary):

https://go.dev - 200 OK - 245ms

The 200 confirms the site is up. The millisecond value tells you how long the round trip took from your machine.

Seeing an error instead of a status code?

  • If you see Error: dial tcp: lookup go.dev: no such host, check your internet connection. The program needs network access to reach external URLs.
  • If you see a compile error mentioning imported and not used, make sure you removed the old import "fmt" line and replaced it with the grouped import block.
  • If you see undefined: Result, make sure the Result struct is defined outside of func main(), not inside it.

Help me debug my Go HTTP request

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and the output shows a status code and response time.

ⓧ I'd like to double check the full code

Scroll up and confirm your main.go contains these sections in this order:

  • package main declaration at the very top.
  • A grouped import block with fmt, net/http, and time.
  • The Result struct with four fields: URL, StatusCode, Duration, Err.
  • The checkURL function that takes a url string and *http.Client, returns a Result.
  • The main() function creating a client with a 10-second timeout, calling checkURL("https://go.dev", client), and printing the result.

You have a working single-URL checker. Next up, you will fire off multiple requests simultaneously using goroutines and channels. That is where Go's concurrency model really shines.

Check Multiple URLs Concurrently

Your health checker works. You can hit a URL, read its status code, and measure the response time. But right now, it only checks one site at a time.

If you needed to check 5 sites, you would wait for each request to finish before starting the next. That could mean 5+ seconds of idle waiting. In this step, you will launch all requests simultaneously using goroutines and collect results through a channel.

In this step, get ready to:
  • Define a list of URLs to check concurrently.
  • Launch one goroutine per URL to fire requests simultaneously.
  • Collect results through a channel as they arrive.
Define URLs and launch goroutines

A goroutine is a lightweight thread managed by the Go runtime. You launch one by putting the go keyword before a function call. A channel is the pipe goroutines use to send data back to each other.

  • In main.go, find the func main() function.
  • Replace everything inside its curly braces with the following code:
// List of URLs to check
urls := []string{
	"https://go.dev",
	"https://google.com",
	"https://github.com",
	"https://cloudflare.com",
	"https://thisdomaindoesnotexist.invalid",
}

// Reuse a single HTTP client (safe for concurrent use)
client := &http.Client{
	Timeout: 10 * time.Second,
}

// Buffered channel to collect results from goroutines
results := make(chan Result, len(urls))

// Launch one goroutine per URL
for _, url := range urls {
	go func(u string) {
		results <- checkURL(u, client)
	}(url)
}

What does this code do?

  • urls is a slice (Go's dynamic array) holding 5 URLs. One is deliberately invalid so you can see how errors flow through.
  • results := make(chan Result, len(urls)) creates a buffered channel that can hold up to 5 Result values without blocking the sender.
  • The go func(u string){...}(url) pattern launches a new goroutine for each URL. All 5 start nearly simultaneously.
  • Inside each goroutine, results <- checkURL(u, client) sends the return value into the channel for collection later.

Why pass the URL as a parameter?

The go func(u string){...}(url) pattern passes url as an argument to the goroutine's function. This gives each goroutine its own copy of the URL value. Without this, all goroutines would share the same loop variable and could end up checking the same URL.

  • Save main.go. The function is incomplete. The next chunk adds the code that reads results from the channel.
Receive results from the channel

Goroutines are sending results into the channel, but nothing is reading them yet. You need a loop that pulls each result out and prints it.

  • Still in main.go, add this code at the bottom of main() (after the goroutine loop, but still inside the function's closing brace):
// Receive one result per URL
for range urls {
	result := <-results
	if result.Err != nil {
		fmt.Printf("FAIL  %s - Error: %s (%dms)\n", result.URL, result.Err, result.Duration.Milliseconds())
	} else {
		fmt.Printf("OK    %s - %d - %dms\n", result.URL, result.StatusCode, result.Duration.Milliseconds())
	}
}

What does this code do?

  • for range urls loops exactly 5 times (once per URL). Each iteration, <-results blocks until a goroutine sends a value.
  • Results arrive in whatever order the HTTP responses come back. The fastest site prints first, not the first URL in the list.
  • If a request fails (DNS error, timeout), result.Err is non-nil and you print the error message instead of a status code.
  • Save main.go.
Run and observe concurrency in action

Before you run this, think about what you expect. You have 5 URLs firing simultaneously. How long should the total run take compared to the slowest single site?

  • Run the program:
go run main.go

You should see output similar to this (the order will likely differ each time you run it):

OK    https://google.com - 200 - 142ms
OK    https://cloudflare.com - 200 - 198ms
OK    https://go.dev - 200 - 245ms
OK    https://github.com - 200 - 312ms
FAIL  https://thisdomaindoesnotexist.invalid - Error: Get "https://thisdomaindoesnotexist.invalid": dial tcp: lookup thisdomaindoesnotexist.invalid: no such host (87ms)

What should I notice?

Two things prove concurrency is working:

  • Results arrive out of order. The first URL in your slice is https://go.dev, but it probably does not print first. Whichever site responds fastest prints first.
  • Total runtime is roughly the time of the slowest single request (a few hundred milliseconds), not the sum of all five. Sequential checking would take 1-2 seconds. Concurrent checking finishes in a fraction of that.

Seeing an error on every URL?

  • Check your internet connection. All 5 URLs need network access.
  • If you see a compile error mentioning results or urls, make sure the receive loop is inside func main() and not accidentally placed outside the closing brace.
  • If only the invalid URL fails, that is correct. The FAIL line for thisdomaindoesnotexist.invalid is expected.

help me debug my concurrent health checker

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go.

ⓧ I'd like to double check the full code

Scroll up and confirm these pieces are present in your main.go, in this order:

  • The import block with fmt, net/http, and time (unchanged from the prior step).
  • The Result struct (unchanged).
  • The checkURL function (unchanged).
  • The new func main() with the urls slice, client, buffered results channel, goroutine loop, and receive loop.

You just went from checking one URL to checking five simultaneously. Your goroutines fire all requests at once, and the channel streams results back as each one finishes. Next up, you will turn this into a real CLI tool that accepts URLs as arguments and prints a summary report.

Add a Summary Report and Command-Line Input

Your health checker fires off concurrent requests and prints results as they arrive. That is great for a demo, but it is not a tool you would actually use.

A real CLI tool accepts input from the user instead of relying on hardcoded values. It also gives you an actionable summary so you know at a glance how many sites are healthy. In this step, you will replace the hardcoded URLs with command-line arguments and add a summary report at the end.

In this step, get ready to:
  • Accept URLs as command-line arguments instead of hardcoding them.
  • Add a summary report showing total checked, successful, failed, and average response time.
  • Compile the project to a self-contained binary you can run anywhere.
Accept URLs from the command line

Right now your URLs are baked into the source code. Every time you want to check a different site, you have to edit main.go and rerun. Go gives you os.Args to read arguments passed on the command line.

  • In main.go, find your import block. It currently looks like this:
import (
	"fmt"
	"net/http"
	"time"
)
  • Replace it with this updated version that adds the "os" package:
import (
	"fmt"
	"net/http"
	"os"
	"time"
)

Why os?

The os package provides access to operating system functionality. os.Args is a slice of strings holding the command-line arguments, starting with the program name at index 0.

  • Now find the hardcoded urls slice inside func main():
urls := []string{
	"https://go.dev",
	"https://google.com",
	"https://github.com",
	"https://cloudflare.com",
	"https://thisdomaindoesnotexist.invalid",
}
  • Replace that entire block with a usage check and the command-line arguments:
if len(os.Args) < 2 {
	fmt.Println("Usage: sitechecker <url1> <url2> ...")
	fmt.Println("Example: sitechecker https://go.dev https://google.com")
	os.Exit(1)
}

urls := os.Args[1:]

What does this code do?

os.Args[0] is always the program name itself. os.Args[1:] gives you everything the user typed after the program name.

If the user provides no URLs, the program prints a helpful usage message and exits with code 1 (indicating an error). This is a standard CLI pattern.

  • Save main.go.
  • Test the usage message by running with no arguments:
go run main.go

You should see your usage message printed, then the program exits.

  • Now test with real URLs by running:
go run main.go https://go.dev https://google.com

You should see results for both URLs, just like before. The difference is you chose which URLs to check from the command line.

Seeing 'undefined: os' or similar?

Make sure you added "os" to your import block (with quotes, inside the parentheses). Go requires every imported package to be listed explicitly.

help me fix an undefined import error in my Go program

Collect results with a WaitGroup and print a summary

Your current code counts receives manually with for range urls. This works, but Go has a more robust pattern using sync.WaitGroup to track goroutines and a closer goroutine that signals when all work is done.

  • In your import block, add "sync" between "os" and "time" so the full block looks like this:
import (
	"fmt"
	"net/http"
	"os"
	"sync"
	"time"
)
  • Now find the goroutine section that starts with results := make(chan Result, len(urls)) and ends after the closing brace of the goroutine loop. Replace it with this code:
results := make(chan Result, len(urls))
var wg sync.WaitGroup

for _, url := range urls {
	wg.Add(1)
	go func(u string) {
		defer wg.Done()
		results <- checkURL(u, client)
	}(url)
}

go func() {
	wg.Wait()
	close(results)
}()

What does this code do?

sync.WaitGroup is a counter. wg.Add(1) increments it before each goroutine starts. defer wg.Done() decrements it when the goroutine finishes.

The closer goroutine waits until the counter hits zero (all goroutines done), then closes the channel. A closed channel lets the receiver know no more values are coming. This is the idiomatic Go pattern for "fan-in" concurrency.

  • Save main.go and confirm it still runs by executing:
go run main.go https://go.dev https://google.com

You should still see the same individual results. The WaitGroup is now managing goroutine completion behind the scenes.

  • Now replace the entire receive loop (from for range urls { through its closing }) with this code that uses range results and calculates a summary:
var successful, failed int
var totalDuration time.Duration

fmt.Println("\n--- Site Health Check Results ---\n")

for result := range results {
	if result.Err != nil {
		fmt.Printf("  FAIL  %s\n        Error: %s (%dms)\n\n", result.URL, result.Err, result.Duration.Milliseconds())
		failed++
	} else {
		status := "OK"
		if result.StatusCode >= 400 {
			status = "ERROR"
			failed++
		} else {
			successful++
			totalDuration += result.Duration
		}
		fmt.Printf("  %s   %s\n        Status: %d | Time: %dms\n\n", status, result.URL, result.StatusCode, result.Duration.Milliseconds())
	}
}

total := successful + failed
fmt.Println("--- Summary ---")
fmt.Printf("  Total: %d | Successful: %d | Failed: %d\n", total, successful, failed)
if successful > 0 {
	avg := totalDuration.Milliseconds() / int64(successful)
	fmt.Printf("  Avg Response Time (successful): %dms\n", avg)
}

What does this code do?

for result := range results automatically loops until the channel is closed. No manual counting needed.

Inside the loop, each result is categorized: errors and 4xx/5xx status codes count as failed, while 2xx codes count as successful. The code accumulates totalDuration only for successful requests so the average is meaningful.

After the loop, the summary divides total duration by the number of successful requests to get the average response time.

  • Save main.go.

Before you run this, what do you expect to see differently from before?

  • Run the program with a mix of valid and invalid URLs:
go run main.go https://go.dev https://google.com https://thisdomaindoesnotexist.invalid

You should see individual results for each URL followed by a summary line showing 3 checked, 2 successful, 1 failed, and an average response time in milliseconds.

Seeing a deadlock or hang?

Make sure you have the closer goroutine (go func() { wg.Wait(); close(results) }()) in place. Without it, range results never knows the channel is done and waits forever.

Also confirm every goroutine has defer wg.Done() so the WaitGroup counter reaches zero.

help me fix a deadlock in my Go channel program

Compile the binary

Go compiles your entire program into a single binary with zero runtime dependencies. No interpreter, no VM, no installed packages needed on the target machine.

  • Build the binary by running:
go build -o sitechecker

What does this command do?

go build compiles your Go source into a native executable. The -o sitechecker flag names the output binary. The result is a standalone file you can copy to any compatible machine and run directly.

  • Confirm the binary was created by running:
ls sitechecker

You should see sitechecker listed.

What do you think will happen when you run the binary directly? Will it behave any differently from go run?

  • Run the compiled binary directly:
./sitechecker https://go.dev https://httpstat.us/500

The output is identical to go run. You should see go.dev as OK and httpstat.us/500 as ERROR with status 500, followed by the summary. The binary runs without Go installed on the machine.

Permission denied?

If you see a permission error, the binary may not be marked as executable. Run chmod +x sitechecker and try again.

help me fix a permission denied error running my Go binary

✔️ Awesome, I've got everything!

Great. Double check you have saved main.go and that your summary shows the correct counts when you run with a mix of valid and invalid URLs.

ⓧ I'd like to double check the full code

Scroll up and confirm your main.go contains each of these sections in order:

  • Import block with fmt, net/http, os, sync, time.
  • Result struct (unchanged from Step 2).
  • checkURL function (unchanged from Step 2).
  • func main() with os.Args check and usage message.
  • urls := os.Args[1:] and the HTTP client.
  • WaitGroup goroutine section with wg.Add(1), defer wg.Done(), and the closer goroutine.
  • Range loop over results channel with counting logic.
  • Summary output (Total, Successful, Failed, Avg Response Time).

Your health checker is now a real CLI tool. You pass URLs, it checks them concurrently, and it gives you a clear summary. Next up, you will push it further with a Secret Mission that caps how many requests fly at once.

Secret mission

Add a Concurrency Limiter

What happens if you point this tool at 1000 URLs? Your OS runs out of file descriptors and servers rate-limit you. In this bonus challenge, you will add a concurrency limiter that caps how many requests fly at once using Go's buffered channel semaphore pattern.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The sitechecker project directory on your Desktop (contains main.go, go.mod, and the compiled sitechecker binary).
  • Go 1.26.5 installation at /usr/local/go.

Keep everything running

No action needed. Choose this if you plan to keep using your site health checker or want to extend it further.

  • The compiled sitechecker binary is yours. You can add it to your PATH and use it for real monitoring.
  • Go remains installed on your machine. You can use it for any future Go projects.
  • Consider extending your tool with JSON output, reading URLs from a file, or adding retry logic.

Pause - I'll come back to this later

Since this project is entirely local, there is nothing to pause. Your project files stay on disk until you decide to delete them.

  • Your project directory and Go installation take up minimal disk space and consume no resources while idle.
  • When you are ready to return, open the sitechecker folder on your Desktop in your code editor and pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and optionally uninstall Go itself.

  • Delete the project directory by running:
rm -rf ~/Desktop/sitechecker
  • To uninstall Go completely, remove the Go directory and the PATH entry that the installer created:
sudo rm -rf /usr/local/go
sudo rm -f /etc/paths.d/go
  • If you installed Go tools via go install, also remove your Go workspace at ~/go.
  • Confirm Go is removed by opening a new terminal window and running go version. You should see command not found.

That's a Wrap!

That's a Wrap!

Nice work! You just built a concurrent site health checker in Go from scratch, going from zero Go experience to a compiled CLI tool that fires off simultaneous HTTP requests and streams results back in real time.

You've learned how to:

  • Use goroutines and channels to check multiple URLs concurrently, with results streaming in as they arrive instead of waiting for all requests to finish.
  • Handle errors as returned values using Go's explicit error handling pattern, where every failure (DNS lookup, timeout, bad status) is a value you inspect rather than an exception you catch.
  • Compile your program to a single self-contained binary with zero runtime dependencies that runs anywhere without Go installed.
  • Secret Mission: Implemented a semaphore-based concurrency limiter using buffered channels, a production pattern used in tools like kubectl and terraform to cap resource usage.

Ready to quiz yourself?