The vanishing act
You write a Go program that spawns a background task. You hit run. The terminal prints "Starting worker..." and then immediately returns to the prompt. The worker never finishes. You check the code. The goroutine is there. The logic is sound. The program just vanished.
This is the classic "main goroutine exits" trap. It happens to almost every Go developer in their first week. The code looks correct, but the process dies before the background work completes. The culprit is not a bug in your logic. It is the fundamental lifecycle rule of the Go runtime.
Main is the anchor
In Go, the main function runs in a special goroutine called the main goroutine. This goroutine is the anchor of the process. When main returns, the Go runtime considers the program finished. It tears down the entire process immediately. Every other goroutine gets killed. There is no grace period. There is no "wait for background tasks." The runtime assumes that if main is done, everything is done.
Think of the main goroutine as the power cord to a server rack. Pull the cord, and every machine in the rack shuts down instantly, regardless of what they were doing. The background goroutines are the machines. They can run as hard as they want, but they only stay alive as long as the cord is plugged in.
This design keeps the runtime simple. The scheduler does not need to track a complex shutdown sequence. It just waits for main to return. If you need other goroutines to finish, you must make main wait. The main goroutine must block until the work is done.
The minimal trap
Here's the code that dies too fast. It spawns a worker, prints a message, and returns. The worker never prints.
package main
import (
"fmt"
"time"
)
func main() {
// Spawn a worker that takes time to finish.
go func() {
time.Sleep(1 * time.Second)
fmt.Println("Worker done")
}()
// Main returns immediately. The process dies here.
fmt.Println("Main exiting")
}
prints:
Main exiting
The output shows "Main exiting" and nothing else. The worker goroutine was scheduled, but main reached the end of the function before the worker could wake up from the sleep. The runtime killed the process. The worker's fmt.Println never executed.
What the runtime does
When you run this program, the scheduler starts the main goroutine. It hits the go func() line and schedules the anonymous function on a different OS thread or timeslice. Control returns to main. main prints "Main exiting" and reaches the end of the function.
The runtime sees main has returned. It triggers a process exit. The scheduler stops. The worker goroutine is still in the queue or running on another thread, but the OS kills the process. The worker's state is discarded. Any resources it held are reclaimed by the OS, not by Go's garbage collector.
This behavior is consistent. It does not matter how many goroutines are running. It does not matter if they are doing heavy computation or waiting on I/O. If main returns, the program ends. The only way to keep the program alive is to prevent main from returning until you are ready.
Keeping main alive with WaitGroup
The standard fix is sync.WaitGroup. A WaitGroup counts active tasks and blocks until the count hits zero. You add to the counter before spawning goroutines. Each goroutine decrements the counter when it finishes. main calls Wait to block until the counter reaches zero.
Here's the corrected pattern using a WaitGroup.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
var wg sync.WaitGroup
// Add one to the counter for the single worker.
wg.Add(1)
go func() {
// Decrement the counter when the worker finishes.
defer wg.Done()
time.Sleep(1 * time.Second)
fmt.Println("Worker finished")
}()
// Block main until the counter reaches zero.
wg.Wait()
fmt.Println("All work complete")
}
prints:
Worker finished
All work complete
The WaitGroup keeps main blocked. The worker runs, sleeps, prints, and calls Done. The counter drops to zero. Wait returns. main continues and exits cleanly.
Convention aside: defer wg.Done() is the standard pattern. It ensures the counter decrements even if the function panics. If you call wg.Done() manually and the function panics before that line, the counter stays positive. main hangs forever. defer prevents that leak.
WaitGroups track counts, not results. Use channels for data.
The server pattern
Not all programs need a WaitGroup. Long-running services like HTTP servers keep main alive by calling a blocking function. The function never returns, so main never exits. Background goroutines stay alive for the lifetime of the process.
Here's how a simple HTTP server keeps the program running.
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
// Background task runs while the server is up.
go func() {
for {
time.Sleep(5 * time.Second)
fmt.Println("Heartbeat")
}
}()
// ListenAndServe blocks main forever.
// The background goroutine stays alive.
http.ListenAndServe(":8080", nil)
}
http.ListenAndServe is a blocking call. It starts the server and waits for connections. It does not return until you shut it down or the program crashes. Because main is blocked inside ListenAndServe, the background goroutine survives. It prints "Heartbeat" every five seconds.
This is how most Go servers work. You spawn background workers, then call a blocking function like ListenAndServe. The workers run until you stop the server. If you need to shut down gracefully, you cancel the context or call a shutdown method, which unblocks main and allows the program to exit.
os.Exit kills everything
There is a second way to end a program: os.Exit. Calling os.Exit stops the world immediately. It does not wait for main to return. It does not run defer statements in main. It kills all goroutines.
Here's the trap with os.Exit.
package main
import (
"fmt"
"os"
)
func main() {
// This defer never runs if os.Exit is called.
defer fmt.Println("Cleanup")
fmt.Println("Exiting")
os.Exit(0)
}
prints:
Exiting
The output shows "Exiting" and nothing else. The defer never runs. os.Exit bypasses the normal return path. It tells the OS to terminate the process right now. Any cleanup logic in defer is skipped. Any goroutines are killed.
Use os.Exit only when you need to force an immediate termination, such as in a test harness or when a critical error makes continued execution impossible. For normal flow, use return. return runs defer statements and allows the runtime to shut down gracefully.
os.Exit is a nuclear option. Use return for normal flow.
Pitfalls and errors
Goroutine synchronization has subtle traps. The most common error is misusing WaitGroup. If you call wg.Done more times than wg.Add, or if you call Done before Add, the runtime panics.
The runtime panics with sync: negative WaitGroup counter. This error means the counter went below zero. It usually happens when a goroutine finishes before main calls Add, or when Done is called twice.
Another pitfall is the goroutine leak. If you use a WaitGroup but the goroutine blocks forever, main hangs. The program appears to freeze. The goroutine is waiting on a channel that never gets closed, or it is stuck in an infinite loop.
Convention aside: Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Use context.Context to signal goroutines to stop.
Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If a goroutine receives a context, it should check ctx.Done() to know when to exit.
The worst goroutine bug is the one that never logs. Add logging to background tasks so you can see if they are running or stuck.
Decision matrix
Concurrency primitives solve specific problems. Pick the right tool for the job.
Use sync.WaitGroup when you need to wait for a fixed set of goroutines to finish.
Use a channel when you need to receive a result or signal from a goroutine.
Use context.Context when you need to cancel long-running work or pass deadlines.
Use sync.Once when you need to ensure a setup task runs exactly one time across multiple goroutines.
Use os.Exit when you need to force immediate termination and skip cleanup.
Use plain sequential code when you don't need concurrency. The simplest thing that works is usually the right thing.
The main goroutine is the heartbeat. If it stops, the program dies.