lightweight concurrent functions managed by the go runtime, not the os
goroutines are go's answer to concurrency. not threads, not callbacks, not promises — just functions that run at the same time with almost no overhead.
a goroutine is a function running concurrently with other goroutines in the same process. the go runtime manages scheduling — you never touch OS threads directly.
each goroutine costs around 2-8kb of stack memory to start (vs ~1mb for an OS thread). you can have thousands without issues.
go fmt.Println("runs concurrently")
go func() {
fmt.Println("anonymous goroutine")
}()just put go in front of any function call. that's it.
goroutines don't return values and main doesn't wait for them. if main exits, every goroutine gets killed immediately — no cleanup, no warning.
go func() {
time.Sleep(2 * time.Second)
fmt.Println("this might never print")
}()
// main exits here, goroutine is killedthe naive fix is time.Sleep in main — but you're guessing how long goroutines take. fragile in production. real fix is sync.WaitGroup.
common mistake. time.Sleep takes a time.Duration which is nanoseconds under the hood.
time.Sleep(300) // 300 nanoseconds — basically nothing
time.Sleep(300 * time.Millisecond) // 300ms
time.Sleep(time.Second) // 1s
time.Sleep(2 * time.Second) // 2s
time.Sleep(time.Second + 500*time.Millisecond) // 1.5sstart := time.Now()
// ... do work ...
fmt.Println("took:", time.Since(start))when multiple goroutines read and write the same variable, results are non-deterministic. you'll get different values every run, with no panic or error — just silently wrong data.
counter := 0
for range 100 {
go func() {
counter++ // not safe
}()
}
// counter will likely not be 100counter++ is three operations: read, increment, write. another goroutine can interrupt between any of them and overwrite your result.
detect races with: go run -race main.go
fix: sync.Mutex or channels.
classic gotcha. goroutines capture a reference to the loop variable, not the value at launch time.
// buggy — all goroutines may print the same value of i
for i := range 5 {
go func() {
fmt.Println(i)
}()
}
// fixed — pass i as argument, each goroutine gets its own copy
for i := range 5 {
go func(i int) {
fmt.Println(i)
}(i)
}go 1.22+ fixes this for range loops but the explicit argument pattern is still clearest.
the real value of goroutines for backend work:
// sequential — total time = sum of all calls
fetchUser() // 3s
fetchOrders() // 2s
fetchProducts() // 4s
// total: 9s
// concurrent — total time = slowest call
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); fetchUser() }()
go func() { defer wg.Done(); fetchOrders() }()
go func() { defer wg.Done(); fetchProducts() }()
wg.Wait()
// total: 4sn independent operations that each take t seconds:
instead of launching one goroutine per job (could be thousands), launch a fixed pool and distribute work.
jobs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
var wg sync.WaitGroup
chunks := [][]int{jobs[0:3], jobs[3:6], jobs[6:9]}
for workerID, chunk := range chunks {
wg.Add(1)
go func(id int, jobs []int) {
defer wg.Done()
for _, job := range jobs {
fmt.Printf("worker %d processing job %d\n", id+1, job)
}
}(workerID, chunk)
}
wg.Wait()the dynamic version (workers pulling from a job queue) uses channels.
why not just launch 1000 goroutines for 1000 jobs? memory. 1000 goroutines at 8kb each is ~8mb just for stacks. the go scheduler also has to track all 1000 even if they're idle. fixed workers = same throughput, fraction of overhead.
goroutines live and die with the main goroutine (the process). main exits, everything stops.
go func() {
for {
fmt.Println("running...")
time.Sleep(500 * time.Millisecond)
// killed mid-loop when main exits
}
}()
time.Sleep(2 * time.Second)
fmt.Println("main done — goroutine above is dead")for goroutines that need graceful shutdown, use context.WithTimeout or context.WithCancel and check ctx.Done() inside the loop.
background job after response — user doesn't wait for side effects:
func handleSignup(w http.ResponseWriter, r *http.Request) {
user := createUser(r)
w.WriteHeader(http.StatusCreated)
go func() {
sendWelcomeEmail(user)
notifySlack(user)
}()
}parallel service calls:
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); getUserData(id) }()
go func() { defer wg.Done(); getUserOrders(id) }()
go func() { defer wg.Done(); getNotifications(id) }()
wg.Wait()other use cases: health checks across multiple services, cache warming on startup, processing uploaded files, websocket connections (one goroutine per client), audit logging without blocking the request.
goroutines alone can't communicate results back — they're fire and forget. to actually get data out of a goroutine you need channels, and to coordinate multiple goroutines properly you need waitgroups and mutexes.