A command-line tool that checks the HTTP status of many URLs concurrently
using a bounded worker pool built from goroutines, channels, and context.
Give it a list of URLs; it tells you which ones are UP or DOWN, how long each
took, and can export the results as a table, JSON, or CSV.
Built with the Go standard library only — no third-party dependencies,
nothing to go get. Clone it and run it.
- Bounded concurrency using a configurable worker pool
- Goroutines and channels for concurrent URL processing
context.Contextfor per-request and overall timeouts- Graceful cancellation with
Ctrl+C/SIGTERM - Deterministic unit tests using
httptest - JSON and CSV report generation
- CI pipeline using GitHub Actions
- Standard-library-only implementation with no third-party dependencies
- Concurrent checking via a configurable worker pool (default 10 workers)
- Context-based timeouts — a per-request timeout and an overall batch
timeout, both cancellable early with
Ctrl+C - Graceful shutdown — interrupting the program marks in-flight/queued checks as DOWN instead of just dying mid-way
- Three output formats — aligned console table, JSON, or CSV
- Sensible exit codes — exits
1if any URL is DOWN, so it's usable as a step in a CI job or cron-based monitor - Input file supports comments (
#) and URLs without a scheme (github.comis treated ashttps://github.com) - Unit tests for every package, using
httptest(no real network calls needed to run the test suite)
url-status-checker/
├── go.mod # module definition (stdlib only)
├── README.md
├── LICENSE
├── Makefile # build / run / test / vet shortcuts
├── urls.txt # sample input file
├── cmd/
│ └── urlchecker/
│ └── main.go # CLI entry point: flags, signals, wiring
└── internal/
├── checker/
│ ├── checker.go # checks ONE url, returns a Result
│ └── checker_test.go
├── worker/
│ ├── pool.go # fans a URL list out across N goroutines
│ └── pool_test.go
└── report/
├── report.go # table / JSON / CSV formatting
└── report_test.go
This is a standard Go CLI layout: cmd/ holds the thin entry point,
internal/ holds packages that are only meant to be imported from inside
this module (the Go compiler enforces that). Each package has one job.
urls.txt
│
▼
main() loads URLs
│
▼
worker.Pool.Run(ctx, urls)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
jobs channel ──> jobs channel ──> jobs channel (buffered, len(urls))
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ worker #1 │ │ worker #2 │ │ worker #N │ <- goroutines
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└──────► results channel ◄───────┘
│
▼
report.SortByURL + PrintTable / WriteJSON / WriteCSV
main.goreads the URL list and builds a singlecontext.Contextthat is cancelled onCtrl+C/SIGTERMor after-overall-timeout.worker.Pool.Runstarts-workersgoroutines. All URLs are pushed onto a bufferedjobschannel, then the channel is closed — this is the standard Go signal for "no more work is coming."- Each worker goroutine ranges over
jobsuntil it's empty, wraps the shared context with a per-check timeout (context.WithTimeout), and callschecker.Check. - Every result (success or failure) is sent to a
resultschannel.checker.Checknever returns a Goerror— every failure mode is captured inside theResultstruct, so the concurrent fan-in code doesn't need special-case error handling. sync.WaitGrouptracks when all workers have finished; once they have,resultsis closed and drained into a slice.- The results are sorted and printed/exported.
- Go 1.22 or later (go.dev/dl) — check with
go version - No external packages, no internet access needed to build
-
Install the official Go extension (
golang.go) if VS Code prompts you. -
Open this folder (
File > Open Folder…→ selecturl-status-checker). -
The first time you open a
.gofile, VS Code may offer to install Go tools (gopls,dlv, etc.) — click Install All. This is optional; the project builds and runs fine without them. -
Open a terminal in VS Code (
Ctrl+`) and run:go run ./cmd/urlchecker
That's it — it reads
urls.txtfrom the project root by default.
# Basic run (uses urls.txt, 10 workers, table output)
go run ./cmd/urlchecker
# Custom input file and worker count
go run ./cmd/urlchecker -input=my-sites.txt -workers=20
# Shorter per-request timeout, export as JSON
go run ./cmd/urlchecker -timeout=2s -format=json -out=results.json
# Export as CSV
go run ./cmd/urlchecker -format=csv -out=results.csv
# Build a standalone binary
go build -o bin/urlchecker ./cmd/urlchecker
./bin/urlchecker -input=urls.txt| Flag | Default | Meaning |
|---|---|---|
-input |
urls.txt |
Path to a text file, one URL per line |
-workers |
10 |
Number of concurrent goroutines |
-timeout |
5s |
Timeout applied to each individual URL check |
-overall-timeout |
30s |
Timeout for the entire batch |
-format |
table |
table, json, or csv |
-out |
(none) | Output file path (used for json/csv) |
Checking 6 URL(s) with 10 worker(s) (per-check timeout 5s, overall timeout 30s)...
URL STATUS CODE LATENCY DETAIL
--- ------ ---- ------- ------
https://github.com UP 200 112ms -
https://go.dev UP 200 98ms -
https://pypi.org UP 200 145ms -
https://this-domain-definitely-does-not-exist... DOWN 0 23ms dial tcp: lookup ...: no such host
https://www.google.com UP 200 87ms -
https://www.wikipedia.org UP 200 134ms -
6 checked | 5 UP | 1 DOWN | took 152ms
Note that the total time (152ms) is close to the slowest single request, not the sum of all six — that's the concurrency working.
go test ./... # run all tests
go test ./... -v # verbose output
make cover # coverage report (or: go test ./... -coverprofile=coverage.out)Tests use net/http/httptest to spin up local, in-process HTTP servers, so
the test suite needs no real network access and is fully deterministic
(including the timeout and "host not found" tests).
- Why a worker pool instead of one goroutine per URL? Unbounded
goroutines (
for _, u := range urls { go check(u) }) can open thousands of simultaneous connections and exhaust file descriptors / rate-limit you against the target servers. A worker pool caps concurrency to a known, configurable number. - Why does
Checknever return anerror? Because every failure mode (bad input, DNS failure, timeout, 5xx) is a valid, expected outcome for a status checker — a DOWN site isn't a bug in this program. Encoding that into theResultitself means the pool and report code can treat every check uniformly. - Why
context.WithTimeoutper check, wrapping a parent context that has its own overall timeout? So a single slow URL can't stall the whole batch (-timeout), while the batch as a whole also has a hard ceiling (-overall-timeout), and both can be cut short instantly byCtrl+Cviasignal.NotifyContext. - Why a named return (
func Check(...) (result Result)) with adefer? SoLatency/LatencyMSare always set on every exit path — including early error returns — without repeating that logic at eachreturn. - Why exclude
Latencyfrom JSON but addLatencyMS?time.Durationmarshals to raw nanoseconds by default, which isn't what a consumer of the JSON output expects;LatencyMSis explicit and human-friendly.
- Retry failed checks N times with backoff before marking DOWN
- Slack/webhook notification when a previously-UP site goes DOWN
-intervalflag to keep re-checking on a schedule instead of running once- Read URLs from stdin as an alternative to
-input
MIT — see LICENSE.