Build a Concurrent HTTP Load Tester in Go
Build a CLI tool that load-tests HTTP endpoints with live terminal metrics.
Introduction
30 Second Summary
When a website goes viral or a flash sale begins, thousands of users hit the same server at once. Engineers use load testing tools to simulate that pressure before real users ever arrive.
In this project, you will build "Blitz," a command-line load testing tool in Go that fires concurrent HTTP requests at any URL and displays live metrics in your terminal.
What You'll Build
Blitz is a multi-package Go CLI that uses goroutines and channels to coordinate a pool of concurrent HTTP workers, configurable attack patterns, and a live-updating terminal dashboard showing latency percentiles and error rates.
By the end of this project, you'll have:
- A Go module with 3 internal packages and 1 command entry point, following Go's idiomatic cmd/internal project layout.
- A goroutine-based worker pool using the fan-out/fan-in channel architecture to fire concurrent HTTP requests.
- An interface-based attack system with two pattern implementations (constant rate and burst mode) and a thread-safe live metrics reporter.
- Secret Mission: Add a ramp-up attack pattern that gradually increases request rate from zero to the target rate over the duration.
Are there any prerequisites?
You need basic programming knowledge (conditionals, loops, functions). No prior Go experience is required. This project uses only Go's standard library with zero external dependencies.
Before We Start
Before We Start...
Initialize the Project
Every Go project starts with a module and a specific folder structure. Go's toolchain relies on this structure to find packages, enforce visibility rules, and compile your code correctly.
In this step, you'll initialize the Blitz module, create the standard directory layout, and write a minimal main function to confirm everything compiles.
In this step, get ready to:
- Initialize a Go module called blitz.
- Create the idiomatic cmd/ and internal/ directory structure.
- Write and run a minimal main.go to verify everything compiles.
Create the project directory and initialize the module
Go uses modules to manage dependencies and define your project's identity. The go mod init command creates a go.mod file that names your module and tracks its Go version.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir blitz
cd blitz
- Initialize the Go module by running this command:
go mod init blitz
You should see: go: creating new go.mod: module blitz.
Seeing 'go: command not found'?
This means Go is not installed or not in your system PATH. Verify your installation by checking the version:
go version
You should see something like go version go1.26.3. If not, download Go from go.dev/dl and restart your terminal after installation.
Help me troubleshoot my Go installation
Now you need the folder structure that Go expects. The official Go documentation recommends placing your binary's entry point in cmd/ and private packages in internal/.
- Create the full directory structure by running this command:
mkdir -p cmd/blitz internal/config internal/attacker internal/report
- Confirm your directories exist by running:
ls
You should see cmd, go.mod, and internal listed in the output.
What is the internal/ directory?
The internal/ directory has a special meaning in Go. The compiler enforces that packages inside internal/ can only be imported by code within the parent directory.
This is how Go provides encapsulation without access modifiers like private or protected. If someone imports your module, they cannot reach into your internal/ packages. The compiler will reject it.
Write and run your first Go file
Every Go binary needs a main package with a main() function. This is the entry point the Go runtime calls when your program starts.
- Open VS Code in your project folder by running this command:
code .
Seeing 'code: command not found'?
The code command needs to be installed separately. Open VS Code manually, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Shell Command: Install 'code' command in PATH, and select it. Then retry code . in your terminal.
- In VS Code's Explorer panel (the file tree on the left), expand the cmd folder, then expand blitz.
- Right-click the blitz folder and select New File.
- Name the file main.go and press Enter.
- Paste the following code into main.go:
package main
import "fmt"
func main() {
// Print a startup message to confirm the binary runs
fmt.Println("Blitz starting...")
}
What does this code do?
- package main tells Go this file is the entry point for a runnable program (not a library).
- import "fmt" brings in Go's formatting package for printing to the terminal.
- func main() is the function Go calls automatically when your program starts. Every Go binary must have exactly one.
- Save main.go by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Back in your terminal (make sure you're in the blitz folder), run your program with this command:
go run ./cmd/blitz
You should see Blitz starting... printed in your terminal.
What does go run ./cmd/blitz do?
The go run command compiles and immediately executes a Go package. The path ./cmd/blitz tells Go to look for a main package in that directory. This is why the cmd/ convention matters. Go knows exactly where to find your entry point.
Seeing an error instead of the message?
- If you see can't load package, check that your file is saved at exactly cmd/blitz/main.go (not at the project root or in the wrong subfolder).
- If you see expected 'package', found 'EOF', the file is empty. Make sure you pasted the code and saved.
- Make sure you're running the command from the blitz directory (where go.mod lives), not from inside cmd/blitz/.
Help me debug my go run error
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's what your cmd/blitz/main.go should look like:
package main
import "fmt"
func main() {
fmt.Println("Blitz starting...")
}
And your go.mod (generated by go mod init) should look like:
module blitz
go 1.26
Your project skeleton is ready. You have a working Go module, a clean directory structure, and a binary that compiles and runs. Next up, you'll fill in the internal/config package with the structs and flag parsing that power Blitz's command-line interface.
Define Configuration and Types
Your project structure is ready. Now you need to define the data shapes that flow through your load tester.
Every Go program works with typed data. In this step, you will create two structs: one that holds all the configuration a user passes via command-line flags, and one that captures the result of a single HTTP request. These types become the contracts that connect your packages together.
In this step, get ready to:
- Define a Config struct with all CLI parameters.
- Write a Parse() function that reads command-line flags into the struct.
- Define a Result struct for capturing HTTP response data.
Create the Config struct
A struct in Go is a composite type that groups related fields together. Your Config struct will hold every parameter the user can set when running Blitz.
- Create a new file called config.go inside the internal/config/ folder, then add the following code:
package config
import (
"time"
)
// Config holds all CLI parameters for the load test
type Config struct {
URL string // Target URL to attack
Workers int // Number of concurrent goroutines
Duration time.Duration // How long the attack runs
Rate int // Requests per second (constant pattern)
Timeout time.Duration // Individual request timeout
Pattern string // Attack pattern name
BurstSize int // Requests per burst (burst pattern)
BurstDelay time.Duration // Pause between bursts
}
What does this code do?
package config declares this file belongs to the config package. Other packages will import it using the path blitz/internal/config.
The Config struct groups eight fields into a single type. Each field has a name, a type, and an inline comment explaining its purpose. The time.Duration type is from Go's standard library and represents a length of time with nanosecond precision.
- Save internal/config/config.go.
- Verify the file compiles by running:
go build ./internal/config/
If it compiles successfully, you will see no output at all. Silence means success in Go's build system.
Seeing a compile error?
- Check that the first line says package config exactly. The package name must match the folder name.
- Make sure the import has "time" in quotes. Go imports are always quoted strings.
- Confirm the file is saved at internal/config/config.go relative to your project root (the folder with go.mod).
Help me fix a Go compile error in my config package
Add the Parse function
The struct defines the shape of configuration data, but you still need a way to fill it with values from the command line. Go's flag standard library package maps command-line flags like -url and -workers directly into struct fields.
- In internal/config/config.go, update your import block to include "flag" and "fmt" alongside "time".
- Below the Config struct, add the following Parse function:
func Parse() (*Config, error) {
cfg := &Config{}
// Bind each flag to a field on the Config struct
flag.StringVar(&cfg.URL, "url", "", "Target URL to attack")
flag.IntVar(&cfg.Workers, "workers", 10, "Number of concurrent workers")
flag.DurationVar(&cfg.Duration, "duration", 10*time.Second, "Attack duration")
flag.IntVar(&cfg.Rate, "rate", 50, "Requests per second (constant pattern)")
flag.DurationVar(&cfg.Timeout, "timeout", 5*time.Second, "Request timeout")
flag.StringVar(&cfg.Pattern, "pattern", "constant", "Attack pattern: constant or burst")
flag.IntVar(&cfg.BurstSize, "burst-size", 20, "Requests per burst (burst pattern)")
flag.DurationVar(&cfg.BurstDelay, "burst-delay", 2*time.Second, "Delay between bursts")
// Parse the command-line arguments
flag.Parse()
// Validate required fields
if cfg.URL == "" {
return nil, fmt.Errorf("url is required: use -url flag")
}
return cfg, nil
}
What does this code do?
- cfg := &Config{} creates a new Config struct and stores a pointer to it. The & operator takes the address of the struct so that flag can write values directly into it.
- Each flag.StringVar / flag.IntVar / flag.DurationVar call binds a flag name (like -workers) to a struct field, with a default value and description.
- The function returns (*Config, error). This is Go's idiomatic pattern for functions that can fail. The caller checks the error first.
- return nil, fmt.Errorf(...) returns a nil pointer and a formatted error message when the URL is missing. This teaches the caller exactly what went wrong.
- Save internal/config/config.go.
- Verify it compiles by running:
go build ./internal/config/
No output means it compiled successfully.
Getting an "imported and not used" error?
- Make sure your import block contains all three packages: "flag", "fmt", and "time". Go refuses to compile if an imported package is unused.
- If you see "undefined: flag" or similar, double-check the import paths are inside parentheses and each one is on its own line.
Help me fix my Go import or compile error
✔️ Awesome, I've got everything!
Great. Double check you have saved config.go before moving on.
ⓧ I'd like to double check the full code
Here is the complete internal/config/config.go file. Make sure yours matches exactly:
package config
import (
"flag"
"fmt"
"time"
)
type Config struct {
URL string
Workers int
Duration time.Duration
Rate int
Timeout time.Duration
Pattern string
BurstSize int
BurstDelay time.Duration
}
func Parse() (*Config, error) {
cfg := &Config{}
flag.StringVar(&cfg.URL, "url", "", "Target URL to attack")
flag.IntVar(&cfg.Workers, "workers", 10, "Number of concurrent workers")
flag.DurationVar(&cfg.Duration, "duration", 10*time.Second, "Attack duration")
flag.IntVar(&cfg.Rate, "rate", 50, "Requests per second (constant pattern)")
flag.DurationVar(&cfg.Timeout, "timeout", 5*time.Second, "Request timeout")
flag.StringVar(&cfg.Pattern, "pattern", "constant", "Attack pattern: constant or burst")
flag.IntVar(&cfg.BurstSize, "burst-size", 20, "Requests per burst (burst pattern)")
flag.DurationVar(&cfg.BurstDelay, "burst-delay", 2*time.Second, "Delay between bursts")
flag.Parse()
if cfg.URL == "" {
return nil, fmt.Errorf("url is required: use -url flag")
}
return cfg, nil
}
Create the Result struct
Your load tester will fire many HTTP requests concurrently. Each request produces a result that needs to travel from a worker goroutine back to the main reporting loop. The Result struct defines what data you capture from each request.
- Create a new file called attacker.go inside the internal/attacker/ folder, then add the following code:
package attacker
import (
"time"
)
// Result captures the outcome of a single HTTP request
type Result struct {
StatusCode int // HTTP status code (200, 404, 500, etc.)
Duration time.Duration // How long the request took
Error error // Non-nil if the request failed
Timestamp time.Time // When the request started
}
What does this code do?
- StatusCode stores the HTTP response code so you can track how many 200s, 404s, or 500s the target returns.
- Duration records how long each individual request took. You will use these values to compute latency percentiles (P50, P95, P99) later.
- Error is Go's built-in error interface. If the request failed (timeout, DNS failure, connection refused), this field captures what went wrong.
- Timestamp records when the request started so you can calculate requests per second over time.
- Save internal/attacker/attacker.go.
- Verify the entire project still compiles by running:
go build ./...
The ./... pattern tells Go to build every package in the module. No output means all three packages (main, config, attacker) compiled cleanly.
Seeing a package error?
- Make sure the first line of your file says package attacker. The package name must match the folder name (attacker).
- Confirm the file path is internal/attacker/attacker.go. If you accidentally placed it in internal/config/, Go will report a package name conflict.
Help me fix my Go package error
✔️ Awesome, I've got everything!
Great. Double check you have saved attacker.go before moving on.
ⓧ I'd like to double check the full code
Here is the complete internal/attacker/attacker.go file:
package attacker
import (
"time"
)
type Result struct {
StatusCode int
Duration time.Duration
Error error
Timestamp time.Time
}
You now have two foundational types ready. Config knows what parameters the user wants, and Result knows what data each request produces. Next up, you will build attack patterns that use Go interfaces to schedule requests in completely different ways.
Build Attack Patterns with Interfaces
Your load tester needs to fire requests at different rhythms. A constant stream tests sustained throughput. Short bursts of rapid fire test how a server handles sudden spikes.
In this step, you'll define an interface that describes what any attack pattern must do, then build two concrete implementations. This is Go's approach to polymorphism. Any type that has the right methods automatically satisfies the interface. No implements keyword needed.
In this step, get ready to:
- Define an AttackPattern interface with two methods.
- Build a ConstantPattern that emits requests at a fixed rate using goroutines and channels.
- Build a BurstPattern that fires rapid batches separated by pauses.
Define the AttackPattern interface
An interface in Go is a contract. It declares method signatures that a type must have, but says nothing about how those methods work internally.
- Open internal/attacker/attacker.go in VS Code.
- Add the following interface definition below the Result struct:
type AttackPattern interface {
Schedule(done <-chan struct{}) <-chan struct{}
Name() string
}
What does this interface do?
Any type that implements both Schedule and Name automatically satisfies this interface.
- Schedule receives a done channel (receive-only, indicated by <-chan) that signals when to stop. It returns a channel that emits a signal each time a request should fire.
- Name returns a string label for display purposes.
You won't see a visible change yet. The next chunk implements this interface with real scheduling logic.
Implement the ConstantPattern
The constant pattern fires requests at a steady, even rate. If you set 50 requests per second, one fires every 20 milliseconds like a metronome.
- In internal/attacker/attacker.go, add the following code below the AttackPattern interface you just wrote:
type ConstantPattern struct {
Rate int
}
func (c *ConstantPattern) Name() string {
return "constant"
}
func (c *ConstantPattern) Schedule(done <-chan struct{}) <-chan struct{} {
tick := make(chan struct{})
go func() {
defer close(tick)
// Calculate interval between requests based on desired rate
interval := time.Second / time.Duration(c.Rate)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
// Send a signal to fire a request
select {
case tick <- struct{}{}:
case <-done:
return
}
}
}
}()
return tick
}
What does this code do?
- ConstantPattern stores the desired requests-per-second rate.
- Schedule creates a new channel, then launches a goroutine (a lightweight concurrent function) with go func().
- time.NewTicker fires at a fixed interval. Each tick means "send one request now."
- The select statement waits on multiple channels at once. If done fires first, the goroutine stops. If the ticker fires, it emits a signal on tick.
- The nested select prevents blocking if nobody is reading from tick yet. It also checks done again to avoid getting stuck.
- defer close(tick) ensures the channel closes when the goroutine exits, signaling downstream consumers that no more values will come.
- Save internal/attacker/attacker.go.
- Verify the project still compiles by running:
go build ./...
You should see no output. No output from go build means the code compiled without errors.
Seeing a compile error?
- If you see syntax error: unexpected <-, check that the channel direction arrows are correct. The parameter is done <-chan struct{} (receive-only) and the return type is <-chan struct{} (also receive-only).
- If you see ticker.C undefined, make sure you are using time.NewTicker (capital T) and accessing its .C field (capital C).
- Double check that your import block at the top still includes "time".
Still stuck? Help me debug this Go compile error in my ConstantPattern implementation
Implement the BurstPattern
The burst pattern fires a batch of requests as fast as possible, then pauses before the next batch. This simulates real traffic spikes like a flash sale going live or a deploy triggering health checks.
- In internal/attacker/attacker.go, add the following code below the ConstantPattern methods:
type BurstPattern struct {
BurstSize int
Delay time.Duration
}
func (b *BurstPattern) Name() string {
return "burst"
}
func (b *BurstPattern) Schedule(done <-chan struct{}) <-chan struct{} {
tick := make(chan struct{})
go func() {
defer close(tick)
for {
// Fire a burst of requests as fast as possible
for i := 0; i < b.BurstSize; i++ {
select {
case tick <- struct{}{}:
case <-done:
return
}
}
// Wait before the next burst
select {
case <-time.After(b.Delay):
case <-done:
return
}
}
}()
return tick
}
What does this code do?
- BurstPattern stores how many requests to fire per burst and how long to wait between bursts.
- The inner for i := 0; i < b.BurstSize; i++ loop fires signals rapidly without any delay between them.
- time.After(b.Delay) creates a one-shot timer. The select waits for either the timer or the done signal, whichever comes first.
- This uses the same interface contract as ConstantPattern. Any code that accepts an AttackPattern can use either implementation without knowing which one it got.
- Save internal/attacker/attacker.go.
- Verify the project compiles by running:
go build ./...
Again, no output means success. Both patterns now satisfy the AttackPattern interface.
Seeing an error about BurstPattern?
- If you see b.BurstSize undefined, check that your struct field names match exactly: BurstSize int and Delay time.Duration. Go is case-sensitive.
- If you see time.After not used, make sure time.After(b.Delay) is inside a select case (not a standalone statement).
Need more help? Help me fix this Go compile error in my BurstPattern implementation
Why interfaces instead of function callbacks?
Go supports first-class functions, so you could pass a scheduling function directly. Interfaces are better here because each pattern carries its own state (rate, burst size, delay) alongside its behavior.
Interfaces also make it easy to add new patterns later without modifying existing code. You just create a new struct with the right methods. This is a core Go design principle.
✔️ Awesome, I've got everything!
Great. Double check you've saved internal/attacker/attacker.go and that go build ./... produces no output.
ⓧ I'd like to double check the full code
Here is the complete internal/attacker/attacker.go file at this point:
package attacker
import (
"time"
)
type Result struct {
StatusCode int
Duration time.Duration
Error error
Timestamp time.Time
}
type AttackPattern interface {
Schedule(done <-chan struct{}) <-chan struct{}
Name() string
}
type ConstantPattern struct {
Rate int
}
func (c *ConstantPattern) Name() string {
return "constant"
}
func (c *ConstantPattern) Schedule(done <-chan struct{}) <-chan struct{} {
tick := make(chan struct{})
go func() {
defer close(tick)
interval := time.Second / time.Duration(c.Rate)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
select {
case tick <- struct{}{}:
case <-done:
return
}
}
}
}()
return tick
}
type BurstPattern struct {
BurstSize int
Delay time.Duration
}
func (b *BurstPattern) Name() string {
return "burst"
}
func (b *BurstPattern) Schedule(done <-chan struct{}) <-chan struct{} {
tick := make(chan struct{})
go func() {
defer close(tick)
for {
for i := 0; i < b.BurstSize; i++ {
select {
case tick <- struct{}{}:
case <-done:
return
}
}
select {
case <-time.After(b.Delay):
case <-done:
return
}
}
}()
return tick
}
You now have two attack patterns ready to go, both satisfying the same interface contract. Next up, you'll build the concurrent worker pool that actually consumes these scheduling signals and fires real HTTP requests.
Build the Concurrent Worker Pool
You have attack patterns that generate scheduling signals. Now you need workers that listen for those signals and fire actual HTTP requests.
This step uses the fan-out/fan-in concurrency pattern. Multiple worker goroutines fan out from a single schedule channel, then fan their results back into a single results channel. This is the same architecture you'll find in production job queues and API gateways.
In this step, get ready to:
- Create worker.go with the RunAttack orchestrator function.
- Write an attack helper function that makes individual HTTP requests with proper context and cleanup.
- Verify the project compiles with the new concurrent worker pool.
Create the RunAttack orchestrator function
The RunAttack function is the heart of Blitz. It accepts a context for cancellation, the target URL, a worker count, a timeout, and an AttackPattern. It returns a receive-only channel that streams results back to the caller.
- Create a new file internal/attacker/worker.go and paste the following code:
package attacker
import (
"context"
"io"
"net/http"
"sync"
"time"
)
// RunAttack orchestrates the concurrent worker pool.
// It spawns N goroutines that consume scheduling signals and fire HTTP requests.
func RunAttack(ctx context.Context, url string, workers int, timeout time.Duration, pattern AttackPattern) <-chan Result {
// Buffered channel prevents workers from blocking if main is busy
results := make(chan Result, workers*10)
// Start the pattern's scheduling goroutine
schedule := pattern.Schedule(ctx.Done())
var wg sync.WaitGroup
// Shared HTTP client - safe for concurrent use across goroutines
client := &http.Client{
Timeout: timeout,
}
// Fan-out: spawn N worker goroutines
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// Each worker ranges over schedule until it closes
for range schedule {
result := attack(ctx, client, url)
select {
case results <- result:
case <-ctx.Done():
return
}
}
}()
}
// Fan-in: closer goroutine waits for all workers then closes results
go func() {
wg.Wait()
close(results)
}()
return results
}
What does this code do?
This function implements fan-out/fan-in in four stages:
- results := make(chan Result, workers*10) creates a buffered channel. The buffer prevents workers from stalling if the main loop is busy rendering the display.
- pattern.Schedule(ctx.Done()) starts the scheduling goroutine you built in the previous step. It returns a channel of timing signals.
- The for loop spawns N goroutines. Each one uses for range schedule to keep consuming signals until the schedule channel closes.
- A separate closer goroutine calls wg.Wait() and then closes the results channel. This signals to the caller that all work is done.
Why is http.Client shared across goroutines?
Go's http.Client is explicitly documented as safe for concurrent use. It maintains an internal connection pool that reuses TCP connections across requests. Creating one client per worker would waste memory and defeat connection reuse.
You won't see a change yet. The RunAttack function calls attack(), which doesn't exist yet. The next chunk adds it.
Write the attack helper function
Each worker calls the attack function to make a single HTTP request. This function uses http.NewRequestWithContext so that requests respect the parent context's cancellation. When the user presses Ctrl+C, all in-flight requests abort immediately.
- In internal/attacker/worker.go, add the following function below the closing brace of RunAttack:
// attack makes a single HTTP request and measures its latency.
// It uses context for cancellation and properly cleans up the response body.
func attack(ctx context.Context, client *http.Client, url string) Result {
start := time.Now()
// Create request with context so cancellation propagates
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Result{
Duration: time.Since(start),
Error: err,
Timestamp: start,
}
}
// Execute the request
resp, err := client.Do(req)
duration := time.Since(start)
if err != nil {
return Result{
Duration: duration,
Error: err,
Timestamp: start,
}
}
// Must read body to EOF and close for TCP connection reuse
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return Result{
StatusCode: resp.StatusCode,
Duration: duration,
Timestamp: start,
}
}
What does this code do?
- http.NewRequestWithContext creates a request tied to the context. If the context is cancelled, the request aborts mid-flight.
- client.Do(req) sends the request. A non-2xx status code does NOT return an error. Only network failures, timeouts, and context cancellations produce errors.
- defer resp.Body.Close() ensures the response body is closed when the function exits. The defer keyword schedules cleanup to run no matter how the function returns.
- io.Copy(io.Discard, resp.Body) reads the entire response body and throws it away. You must do this for Go's HTTP client to reuse the underlying TCP connection.
- Save internal/attacker/worker.go.
- Verify the project compiles by running this command from your blitz project folder:
go build ./...
If you see no output, the build succeeded. Go only prints output when something goes wrong.
Seeing a compile error?
- If you see undefined: attack, make sure the attack function is in the same file (worker.go) and has the same package name package attacker at the top.
- If you see imported and not used, check that your import block exactly matches the code above. Go does not allow unused imports.
- If you see undefined: Result or undefined: AttackPattern, confirm that attacker.go is in the same internal/attacker/ directory. Types defined in one file are visible to all other files in the same package.
Help me debug my Go compile error in worker.go
✔️ Awesome, I've got everything!
Great. Double check you've saved worker.go and the build passes cleanly.
ⓧ I'd like to double check the full code
Here is the complete internal/attacker/worker.go file:
package attacker
import (
"context"
"io"
"net/http"
"sync"
"time"
)
func RunAttack(ctx context.Context, url string, workers int, timeout time.Duration, pattern AttackPattern) <-chan Result {
results := make(chan Result, workers*10)
schedule := pattern.Schedule(ctx.Done())
var wg sync.WaitGroup
client := &http.Client{
Timeout: timeout,
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for range schedule {
result := attack(ctx, client, url)
select {
case results <- result:
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
return results
}
func attack(ctx context.Context, client *http.Client, url string) Result {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return Result{
Duration: time.Since(start),
Error: err,
Timestamp: start,
}
}
resp, err := client.Do(req)
duration := time.Since(start)
if err != nil {
return Result{
Duration: duration,
Error: err,
Timestamp: start,
}
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return Result{
StatusCode: resp.StatusCode,
Duration: duration,
Timestamp: start,
}
}
Your worker pool is ready to fire concurrent requests and stream results back through a channel. Next up, you'll build the live metrics reporter that consumes those results and renders a real-time dashboard in your terminal.
Build the Live Metrics Reporter
Your worker pool is firing requests and streaming Result structs through a channel. But raw results aren't useful without a way to aggregate and display them.
In this step, you'll build a thread-safe metrics reporter that collects results from multiple goroutines, computes live statistics like requests per second and latency percentiles, and renders a dashboard that redraws in place using ANSI escape codes.
In this step, get ready to:
- Create the Report struct with a mutex for thread-safe access from multiple goroutines.
- Write a live Render method that clears and redraws the terminal dashboard.
- Implement percentile computation helpers and a final summary method.
Create the Report struct and constructor
Multiple worker goroutines will send results to the reporter simultaneously. Without protection, they'd corrupt the shared data. Go's sync.Mutex provides a lock that only one goroutine can hold at a time.
- Create the file internal/report/report.go inside your blitz project folder by running:
touch internal/report/report.go
- Confirm the file exists by running:
ls internal/report/
You should see report.go in the output.
- Open internal/report/report.go in VS Code and add the Report struct, its constructor, and the Record method by pasting this code:
package report
import (
"fmt"
"math"
"sort"
"sync"
"time"
"blitz/internal/attacker"
)
// Report aggregates results from all workers in a thread-safe way
type Report struct {
mu sync.Mutex
results []attacker.Result
startTime time.Time
totalReqs int
successReqs int
errorReqs int
statusCodes map[int]int
}
// NewReport creates a report with an initialized map and start time
func NewReport() *Report {
return &Report{
startTime: time.Now(),
statusCodes: make(map[int]int),
}
}
// Record safely adds a result and updates counters
func (r *Report) Record(result attacker.Result) {
r.mu.Lock()
defer r.mu.Unlock()
r.results = append(r.results, result)
r.totalReqs++
if result.Error != nil {
r.errorReqs++
return
}
r.successReqs++
r.statusCodes[result.StatusCode]++
}
What does this code do?
The Report struct embeds a sync.Mutex directly as a field. Any goroutine that wants to read or write shared data must first call r.mu.Lock(). The defer r.mu.Unlock() guarantees the lock is released when the function returns, even if a panic occurs.
NewReport() is a constructor function that returns a pointer to a fully initialized Report. The make(map[int]int) call is required because Go maps must be initialized before use. A nil map panics on write.
You won't see a change yet. The next substep adds the display logic that renders this data as a live dashboard.
Why include all imports now?
Go's compiler rejects unused imports. The fmt, math, and sort imports won't be used until you add the remaining functions below. The file won't compile until the full code is in place. That's expected.
Write the Render method for the live dashboard
A load tester needs to show progress in real time. Instead of printing new lines that scroll away, you'll use ANSI escape codes to clear the screen and redraw the dashboard in place.
- In internal/report/report.go, add the Render() method below the Record() function by pasting this code:
// Render builds the live dashboard string with ANSI escape codes
func (r *Report) Render() string {
r.mu.Lock()
defer r.mu.Unlock()
elapsed := time.Since(r.startTime)
rps := float64(r.totalReqs) / elapsed.Seconds()
var latencies []time.Duration
for _, res := range r.results {
if res.Error == nil {
latencies = append(latencies, res.Duration)
}
}
p50, p95, p99 := computePercentiles(latencies)
output := fmt.Sprintf(
"\033[2J\033[H"+
"⚡ BLITZ - Load Test Report\n"+
"══════════════════════════════════════\n"+
" Elapsed: %s\n"+
" Requests: %d (%.1f req/s)\n"+
" Success: %d | Errors: %d\n"+
"──────────────────────────────────────\n"+
" Latency (ms):\n"+
" P50: %6.1f P95: %6.1f P99: %6.1f\n"+
"──────────────────────────────────────\n"+
" Status Codes:\n",
elapsed.Round(time.Second),
r.totalReqs, rps,
r.successReqs, r.errorReqs,
float64(p50.Microseconds())/1000.0,
float64(p95.Microseconds())/1000.0,
float64(p99.Microseconds())/1000.0,
)
for code, count := range r.statusCodes {
output += fmt.Sprintf(" %d: %d\n", code, count)
}
output += "══════════════════════════════════════\n"
return output
}
What does this code do?
- \033[2J clears the entire terminal screen. \033[H moves the cursor back to position (1,1). Together, they make the dashboard redraw in place instead of scrolling.
- The method locks the mutex before reading any shared state. This prevents data races if a worker goroutine calls Record() at the same time.
- Latency percentiles (P50, P95, P99) are computed from only successful requests. Errors don't have meaningful response times.
What are latency percentiles?
P50 means 50% of requests completed faster than this value. P95 means 95% were faster. P99 means 99% were faster. The gap between P50 and P99 reveals tail latency, which is where slow outliers hide.
You won't see output yet. The next substep adds the math helpers that Render() calls, completing the file so it compiles.
Add the percentile helpers and Final method
The Render() method calls computePercentiles which doesn't exist yet. You also need a Final() method that prints a summary after the attack completes, including average latency.
- In internal/report/report.go, add the Final() method and all helper functions below the Render() method by pasting this code:
// Final produces the end-of-attack summary with average latency
func (r *Report) Final() string {
r.mu.Lock()
defer r.mu.Unlock()
elapsed := time.Since(r.startTime)
rps := float64(r.totalReqs) / elapsed.Seconds()
var latencies []time.Duration
for _, res := range r.results {
if res.Error == nil {
latencies = append(latencies, res.Duration)
}
}
p50, p95, p99 := computePercentiles(latencies)
avg := computeAverage(latencies)
output := fmt.Sprintf(
"\n⚡ BLITZ - Final Report\n"+
"══════════════════════════════════════\n"+
" Duration: %s\n"+
" Requests: %d (%.1f req/s)\n"+
" Success: %d | Errors: %d\n"+
"──────────────────────────────────────\n"+
" Latency (ms):\n"+
" Avg: %6.1f P50: %6.1f\n"+
" P95: %6.1f P99: %6.1f\n"+
"──────────────────────────────────────\n"+
" Status Codes:\n",
elapsed.Round(time.Millisecond),
r.totalReqs, rps,
r.successReqs, r.errorReqs,
float64(avg.Microseconds())/1000.0,
float64(p50.Microseconds())/1000.0,
float64(p95.Microseconds())/1000.0,
float64(p99.Microseconds())/1000.0,
)
for code, count := range r.statusCodes {
output += fmt.Sprintf(" %d: %d\n", code, count)
}
output += "══════════════════════════════════════\n"
return output
}
// computePercentiles sorts a copy of the latency slice and extracts P50/P95/P99
func computePercentiles(latencies []time.Duration) (p50, p95, p99 time.Duration) {
if len(latencies) == 0 {
return 0, 0, 0
}
sorted := make([]time.Duration, len(latencies))
copy(sorted, latencies)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i] < sorted[j]
})
p50 = percentile(sorted, 50)
p95 = percentile(sorted, 95)
p99 = percentile(sorted, 99)
return
}
// percentile finds the value at a given percentile using ceiling index math
func percentile(sorted []time.Duration, pct float64) time.Duration {
if len(sorted) == 0 {
return 0
}
idx := int(math.Ceil(pct/100.0*float64(len(sorted)))) - 1
if idx < 0 {
idx = 0
}
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
// computeAverage calculates the mean latency across all successful requests
func computeAverage(latencies []time.Duration) time.Duration {
if len(latencies) == 0 {
return 0
}
var total time.Duration
for _, l := range latencies {
total += l
}
return total / time.Duration(len(latencies))
}
What does this code do?
- Final() is like Render() but without the screen-clearing escape codes. It also includes average latency for the complete summary.
- computePercentiles creates a copy of the slice with copy() before sorting. This avoids mutating the original results slice that other methods still read.
- sort.Slice takes a closure (anonymous function) as its comparison argument. This is how Go passes behaviour as data.
- The percentile helper uses math.Ceil to compute the ceiling index. For P99 with 100 values, this gives index 99 (the highest value).
- Save internal/report/report.go.
- Verify the entire project compiles by running:
go build ./...
If the command produces no output, your code compiled successfully. Go only prints output when something is wrong.
Seeing a compile error?
- If you see imported and not used, make sure you added all three sections of code (struct + Record, Render, and helpers + Final). All imports are used across the complete file.
- If you see undefined: computePercentiles, the helper functions at the bottom of the file are missing. Make sure you pasted the final code block containing computePercentiles, percentile, and computeAverage.
- If you see cannot refer to unexported name, check that your package declaration at the top says package report and that the file is inside the internal/report/ directory.
Help me debug my report.go compile error
✔️ Awesome, I've got everything!
Great. Double check you've saved internal/report/report.go and the build passes cleanly.
ⓧ I'd like to double check the full code
Here is the complete internal/report/report.go file. Compare it with yours and fix any differences.
package report
import (
"fmt"
"math"
"sort"
"sync"
"time"
"blitz/internal/attacker"
)
type Report struct {
mu sync.Mutex
results []attacker.Result
startTime time.Time
totalReqs int
successReqs int
errorReqs int
statusCodes map[int]int
}
func NewReport() *Report {
return &Report{
startTime: time.Now(),
statusCodes: make(map[int]int),
}
}
func (r *Report) Record(result attacker.Result) {
r.mu.Lock()
defer r.mu.Unlock()
r.results = append(r.results, result)
r.totalReqs++
if result.Error != nil {
r.errorReqs++
return
}
r.successReqs++
r.statusCodes[result.StatusCode]++
}
func (r *Report) Render() string {
r.mu.Lock()
defer r.mu.Unlock()
elapsed := time.Since(r.startTime)
rps := float64(r.totalReqs) / elapsed.Seconds()
var latencies []time.Duration
for _, res := range r.results {
if res.Error == nil {
latencies = append(latencies, res.Duration)
}
}
p50, p95, p99 := computePercentiles(latencies)
output := fmt.Sprintf(
"\033[2J\033[H"+
"⚡ BLITZ - Load Test Report\n"+
"══════════════════════════════════════\n"+
" Elapsed: %s\n"+
" Requests: %d (%.1f req/s)\n"+
" Success: %d | Errors: %d\n"+
"──────────────────────────────────────\n"+
" Latency (ms):\n"+
" P50: %6.1f P95: %6.1f P99: %6.1f\n"+
"──────────────────────────────────────\n"+
" Status Codes:\n",
elapsed.Round(time.Second),
r.totalReqs, rps,
r.successReqs, r.errorReqs,
float64(p50.Microseconds())/1000.0,
float64(p95.Microseconds())/1000.0,
float64(p99.Microseconds())/1000.0,
)
for code, count := range r.statusCodes {
output += fmt.Sprintf(" %d: %d\n", code, count)
}
output += "══════════════════════════════════════\n"
return output
}
func (r *Report) Final() string {
r.mu.Lock()
defer r.mu.Unlock()
elapsed := time.Since(r.startTime)
rps := float64(r.totalReqs) / elapsed.Seconds()
var latencies []time.Duration
for _, res := range r.results {
if res.Error == nil {
latencies = append(latencies, res.Duration)
}
}
p50, p95, p99 := computePercentiles(latencies)
avg := computeAverage(latencies)
output := fmt.Sprintf(
"\n⚡ BLITZ - Final Report\n"+
"══════════════════════════════════════\n"+
" Duration: %s\n"+
" Requests: %d (%.1f req/s)\n"+
" Success: %d | Errors: %d\n"+
"──────────────────────────────────────\n"+
" Latency (ms):\n"+
" Avg: %6.1f P50: %6.1f\n"+
" P95: %6.1f P99: %6.1f\n"+
"──────────────────────────────────────\n"+
" Status Codes:\n",
elapsed.Round(time.Millisecond),
r.totalReqs, rps,
r.successReqs, r.errorReqs,
float64(avg.Microseconds())/1000.0,
float64(p50.Microseconds())/1000.0,
float64(p95.Microseconds())/1000.0,
float64(p99.Microseconds())/1000.0,
)
for code, count := range r.statusCodes {
output += fmt.Sprintf(" %d: %d\n", code, count)
}
output += "══════════════════════════════════════\n"
return output
}
func computePercentiles(latencies []time.Duration) (p50, p95, p99 time.Duration) {
if len(latencies) == 0 {
return 0, 0, 0
}
sorted := make([]time.Duration, len(latencies))
copy(sorted, latencies)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i] < sorted[j]
})
p50 = percentile(sorted, 50)
p95 = percentile(sorted, 95)
p99 = percentile(sorted, 99)
return
}
func percentile(sorted []time.Duration, pct float64) time.Duration {
if len(sorted) == 0 {
return 0
}
idx := int(math.Ceil(pct/100.0*float64(len(sorted)))) - 1
if idx < 0 {
idx = 0
}
if idx >= len(sorted) {
idx = len(sorted) - 1
}
return sorted[idx]
}
func computeAverage(latencies []time.Duration) time.Duration {
if len(latencies) == 0 {
return 0
}
var total time.Duration
for _, l := range latencies {
total += l
}
return total / time.Duration(len(latencies))
}
Your metrics reporter is ready to collect results and render a live dashboard. Next up, you'll wire all four packages together in main.go and run your first real load test.
Wire Everything Together and Test
You've built all the individual components: a config parser, attack patterns, a worker pool, and a live metrics reporter. They're sitting in separate packages, ready to be connected.
In this step, you'll rewrite main.go to orchestrate all three packages. The entry point will parse CLI flags, select an attack pattern, launch concurrent workers, and run an event loop that streams results to a live dashboard.
In this step, get ready to:
- Import and connect all three internal packages in main.go.
- Add context-based timeout and graceful Ctrl+C handling.
- Test the tool against a live HTTP endpoint with both attack patterns.
Import packages and select the attack pattern
The main.go file currently just prints a placeholder message. You need to replace it with real orchestration code that ties your config, attacker, and report packages together.
- Open cmd/blitz/main.go in VS Code.
- Delete all the existing code in the file.
- Replace it with the following:
package main
import (
"context"
"fmt"
"os"
"os/signal"
"time"
"blitz/internal/attacker"
"blitz/internal/config"
"blitz/internal/report"
)
func main() {
// Parse CLI flags into a Config struct
cfg, err := config.Parse()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Select the attack pattern based on the -pattern flag
pattern := selectPattern(cfg)
// Print a startup banner so the user knows what's running
fmt.Printf("⚡ Blitz starting: %s pattern | %d workers | %s duration\n", pattern.Name(), cfg.Workers, cfg.Duration)
fmt.Printf(" Target: %s\n\n", cfg.URL)
}
// selectPattern maps the config's pattern name to a concrete implementation
func selectPattern(cfg *config.Config) attacker.AttackPattern {
switch cfg.Pattern {
case "burst":
return &attacker.BurstPattern{
BurstSize: cfg.BurstSize,
Delay: cfg.BurstDelay,
}
default:
return &attacker.ConstantPattern{
Rate: cfg.Rate,
}
}
}
What does this code do?
- The import block pulls in standard library packages (context, os/signal, time) plus your three internal packages.
- config.Parse() returns a pointer and an error. If parsing fails, the error is printed to stderr and the program exits with code 1.
- selectPattern uses a switch statement to return the correct AttackPattern implementation based on the CLI flag. The default case handles anything that isn't "burst" by returning the constant pattern.
- The function returns attacker.AttackPattern (the interface type), so main() never knows which concrete struct it gets. This is polymorphism in Go.
- Save cmd/blitz/main.go.
- Verify the error handling works by running this command without the -url flag:
go run ./cmd/blitz
You should see: Error: url is required: use -url flag.
Seeing an import error?
- If you see cannot find package, make sure your go.mod declares module blitz (not a different module name). The import paths must match exactly.
- Check that your folder structure is correct: internal/config/config.go, internal/attacker/attacker.go, and internal/report/report.go must all exist at those exact paths.
- If you see imported and not used for report, that's expected right now. Go doesn't allow unused imports. You'll use the report package in the next chunk.
Help me debug my Go import errors in the Blitz project.
Add context, signal handling, and the event loop
Right now, main() prints the banner and exits. You need to add the actual orchestration: a timeout context that stops the attack after the configured duration, a signal handler for graceful Ctrl+C, and an event loop that receives results from workers.
- In cmd/blitz/main.go, find the two fmt.Printf lines inside main() (the banner lines).
- Replace the closing } of main() with the following code, then add the closing } after it:
// Create a context that auto-cancels after the configured duration
ctx, cancel := context.WithTimeout(context.Background(), cfg.Duration)
defer cancel()
// Set up graceful shutdown on Ctrl+C
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
go func() {
<-sigCh
fmt.Println("\n\n⚠ Interrupted! Generating final report...")
cancel()
}()
// Launch the attack and create the report collector
rep := report.NewReport()
results := attacker.RunAttack(ctx, cfg.URL, cfg.Workers, cfg.Timeout, pattern)
// Refresh the live display every 200ms
displayTicker := time.NewTicker(200 * time.Millisecond)
defer displayTicker.Stop()
// Event loop: receive results and periodically render the dashboard
for {
select {
case result, ok := <-results:
if !ok {
fmt.Print(rep.Final())
return
}
rep.Record(result)
case <-displayTicker.C:
fmt.Print(rep.Render())
}
}
What does this code do?
- context.WithTimeout creates a context that automatically fires its Done() channel after cfg.Duration. This is how the attack knows when to stop.
- The signal handler spawns a goroutine that blocks on sigCh. When the user presses Ctrl+C, the goroutine calls cancel() to propagate cancellation through the context tree.
- attacker.RunAttack returns a receive-only channel. Results stream in as workers complete requests.
- The for/select loop is the event loop. It multiplexes two channels: incoming results (recorded immediately) and a 200ms ticker (triggers a dashboard redraw). When the results channel closes, it prints the final report and exits.
Why for/select instead of a simple for loop?
A plain for range results loop would block until a new result arrives. The dashboard would only update when a result comes in. By combining two channels in a select, Go wakes up on whichever event fires first. This gives you a smooth 200ms refresh rate regardless of how fast results arrive.
- Save cmd/blitz/main.go.
- Confirm the project compiles by running:
go build ./cmd/blitz
You should see no output if it compiles successfully. If there are errors, check for missing braces or typos in the code you just added.
Seeing a compile error?
- If you see undefined: report or undefined: attacker, check that all six import paths are present in the import block at the top of the file.
- A syntax error: unexpected } usually means a misplaced brace. Make sure main() has exactly one closing } at the very end of the function (after the for/select block).
Help me fix my compile error in main.go.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on to testing.
ⓧ I'd like to double check the full code
Here's the complete cmd/blitz/main.go file:
package main
import (
"context"
"fmt"
"os"
"os/signal"
"time"
"blitz/internal/attacker"
"blitz/internal/config"
"blitz/internal/report"
)
func main() {
cfg, err := config.Parse()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
pattern := selectPattern(cfg)
fmt.Printf("⚡ Blitz starting: %s pattern | %d workers | %s duration\n", pattern.Name(), cfg.Workers, cfg.Duration)
fmt.Printf(" Target: %s\n\n", cfg.URL)
ctx, cancel := context.WithTimeout(context.Background(), cfg.Duration)
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
go func() {
<-sigCh
fmt.Println("\n\n⚠ Interrupted! Generating final report...")
cancel()
}()
rep := report.NewReport()
results := attacker.RunAttack(ctx, cfg.URL, cfg.Workers, cfg.Timeout, pattern)
displayTicker := time.NewTicker(200 * time.Millisecond)
defer displayTicker.Stop()
for {
select {
case result, ok := <-results:
if !ok {
fmt.Print(rep.Final())
return
}
rep.Record(result)
case <-displayTicker.C:
fmt.Print(rep.Render())
}
}
}
func selectPattern(cfg *config.Config) attacker.AttackPattern {
switch cfg.Pattern {
case "burst":
return &attacker.BurstPattern{
BurstSize: cfg.BurstSize,
Delay: cfg.BurstDelay,
}
default:
return &attacker.ConstantPattern{
Rate: cfg.Rate,
}
}
}
Test with constant and burst patterns
Your tool is fully wired. Time to fire it at a real HTTP endpoint and watch the live dashboard in action.
- Run the constant-rate attack against httpbin.org by running:
go run ./cmd/blitz -url http://httpbin.org/get -workers 5 -duration 10s -rate 20
You should see the live dashboard redraw in your terminal every 200ms. It shows requests per second, latency percentiles (P50/P95/P99), success and error counts, and a status code breakdown. After 10 seconds, the final report prints and the program exits.
What do the numbers mean?
- P50 means half of all requests completed faster than this value.
- P95/P99 show the tail latency. These are the slowest requests and what production engineers care most about.
- Status code 200 means success. If you see 429, that means the server rate-limited you. That's your tool working correctly.
- Now test burst mode by running:
go run ./cmd/blitz -url http://httpbin.org/get -workers 5 -duration 10s -pattern burst -burst-size 10 -burst-delay 1s
Notice the difference. In burst mode, you'll see requests/second spike in waves. Ten requests fire rapidly, then a 1-second pause, then another burst. The constant pattern produces a steady stream instead.
- Try pressing Ctrl+C during an attack. You should see the interruption message followed by the final report.
Why does Ctrl+C produce a clean report?
When you press Ctrl+C, the OS sends an interrupt signal. Your signal handler goroutine receives it and calls cancel(). This cascades through the context: the schedule channel closes, workers finish their current request, the WaitGroup completes, and the results channel closes. The for/select loop exits gracefully and prints the final report.
Seeing 100% errors?
- If all requests show as errors, check your internet connection. The tool needs to reach the target URL.
- If you see context deadline exceeded errors, your request timeout (default 5s) might be too short for the target. Try adding -timeout 10s to the command.
- If the dashboard never appears, make sure your terminal supports ANSI escape codes. Most modern terminals do.
Help me debug why my Blitz load tester is showing all errors.
Httpbin has rate limits
If you see 429 status codes mixed with 200s, that's httpbin.org throttling you. Your tool is working perfectly. The status code distribution in the dashboard tells you exactly how the server responded under load.
Secret mission
Add a Ramp-Up Pattern
Real load testers don't slam servers at full speed from the first millisecond. They warm up. Build a third attack pattern that gradually increases the request rate from 0 to max over the duration, and watch your live dashboard show the climb in real-time.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Go's standard library, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your project files for future experimentation, or delete them to free up disk space.
Resources you used:
- The blitz project directory on your Desktop.
- Go source files across 4 packages (cmd/blitz, internal/config, internal/attacker, internal/report).
- The go.mod module file.
- Go installation on your system.
Keep everything running
No action needed. Choose this if you want to keep experimenting with Blitz, try different attack patterns, or extend the tool further.
- Your project files stay in the blitz folder on your Desktop.
- You can run the tool anytime with go run ./cmd/blitz from inside the project directory.
- Keep Go installed if you plan to build more Go projects.
Pause - I'll come back to this later
Since this is a local project with no running processes, there is nothing to pause. Your files sit on disk and consume no resources until you run the tool again.
- If Blitz is currently running in your terminal, press Ctrl+C to stop it.
- Your project files remain on your Desktop whenever you want to return.
Delete - I don't want to use this again
Remove all project files to start fresh. This deletes the entire project directory.
- If Blitz is currently running, press Ctrl+C to stop it.
- Delete the project folder by running:
rm -rf ~/Desktop/blitz
- If you no longer need Go for other projects, you can uninstall it by following the instructions at go.dev/doc/manage-install.
Nice Work!
Nice Work!
You just built a real infrastructure tool in Go from scratch. Blitz is a fully functional concurrent HTTP load tester that fires requests, coordinates workers through channels, and renders live metrics in your terminal.
Here's what you learned:
- Built a multi-package Go CLI tool using the idiomatic cmd/internal layout with interface-based polymorphism for swappable attack patterns.
- Used goroutines and channels to build a concurrent worker pool following the fan-out/fan-in pattern with sync.WaitGroup for clean shutdown.
- Built a thread-safe live metrics reporter using sync.Mutex, latency percentile math, and ANSI escape codes for in-place terminal rendering.
- Secret Mission: Added a ramp-up attack pattern that gradually increases request rate using dynamic goroutine timing.
Ready to quiz yourself?