Is your Go backend handling requests sequentially? You're using only 20% of the engine. Go's native concurrency — goroutines and channels — is its most underestimated superpower. No external libraries, no message queues required. Just clean communication between concurrent pieces.
We, at Meteora Web, chose Go for several backend projects precisely for this reason: writing clean concurrent code without a messaging framework. In this guide we'll dive into Go's CSP (Communicating Sequential Processes) model: goroutines as lightweight actors, channels as pipelines. Ready to stop using Go like it's PHP?
Goroutines: Cheap, Lightweight, and Easy to Launch
A goroutine is a lightweight thread managed by the Go runtime. It starts with about 4KB of stack (vs. MB for an OS thread). You can launch hundreds of thousands without breaking a sweat.
package main
import (
"fmt"
"time"
)
func printMsg(msg string) {
for i := 0; i < 3; i++ {
fmt.Println(msg, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go printMsg("goroutine") // launched in background
printMsg("main") // runs on the main goroutine
time.Sleep(1 * time.Second)
}Problem: the main function doesn't wait. When it ends, all goroutines die. You need WaitGroups.
Sponsored Protocol
sync.WaitGroup: Wait for Them to Finish
var wg sync.WaitGroup
func work(id int) {
defer wg.Done()
fmt.Printf("Work %d started\n", id)
time.Sleep(200 * time.Millisecond)
fmt.Printf("Work %d finished\n", id)
}
func main() {
for i := 1; i <= 5; i++ {
wg.Add(1)
go work(i)
}
wg.Wait()
fmt.Println("All done")
}Common mistake: calling wg.Add inside the goroutine instead of before it. Always do it before the go statement. We see this often in code reviews: guaranteed deadlock.
Channels: Pipes That Carry Data Between Goroutines
A channel is a typed pipe (chan Type) that blocks on read until data arrives, and blocks on write until the buffer is free (buffered) or a reader is ready (unbuffered).
ch := make(chan string) // unbuffered
chBuff := make(chan int, 5) // buffered, capacity 5Unbuffered: Pure Synchronous
A writer sends → blocks until a reader receives. Perfect for notifications and synchronization.
func main() {
ch := make(chan string)
go func() {
ch <- "Hello from goroutine"
}()
msg := <-ch
fmt.Println(msg)
}If you write without ever reading — deadlock. Go runtime catches it and panics.
Sponsored Protocol
Buffered: Internal Queue
You can send until the buffer is full, then it blocks. Useful for bursty loads.
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // blocking: buffer full
fmt.Println(<-ch) // 1When to use? When the producer is faster than the consumer for short bursts. But beware: a large buffer can hide backpressure issues.
Closing a Channel
The sender closes with close(ch). The receiver can iterate with for v := range ch or check with the comma form v, ok := <-ch.
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}
func main() {
ch := make(chan int)
go producer(ch)
for v := range ch {
fmt.Println(v)
}
}Golden rule: only the sender closes. Never the receiver. And don't write to a closed channel — panic.
Select: The Channel Multiplexer
Used to wait on multiple channel operations simultaneously. Classic use case: timeouts or priority.
select {
case msg1 := <-ch1:
fmt.Println("From ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("From ch2:", msg2)
case <-time.After(1 * time.Second):
fmt.Println("Timeout!")
}
If multiple cases are ready, Go picks one randomly (fairness). You can add a default case to never block.
Sponsored Protocol
Real-World Concurrency Patterns
Worker Pool
Launch N goroutines that pull work from a channel. The main function produces jobs and closes the channel when done.
func worker(id int, jobs <-chan int, results chan<- int) {
for j := range jobs {
results <- j * 2 // simulate work
}
}
func main() {
jobs := make(chan int, 100)
results := make(chan int, 100)
// launch 3 workers
for w := 1; w <= 3; w++ {
go worker(w, jobs, results)
}
// send 5 jobs
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
// collect results
for r := 1; r <= 5; r++ {
fmt.Println(<-results)
}
}Ideal for batch processing, scraping, file processing.
Fan-In / Fan-Out
- Fan-Out: one producer distributes work to multiple goroutines (e.g., worker pool).
- Fan-In: multiple goroutines send into the same channel.
func fanIn(ch1, ch2 <-chan string) <-chan string {
c := make(chan string)
go func() {
for {
select {
case v := <-ch1:
c <- v
case v := <-ch2:
c <- v
}
}
}()
return c
}Perfect for aggregating results from multiple sources (APIs, databases, files).
Sponsored Protocol
Pipeline with Channels
Each step is a goroutine that receives from one channel and sends to another. Clean chaining.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
c := gen(2, 3, 4)
out := sq(c)
for n := range out {
fmt.Println(n) // 4, 9, 16
}
}Handling Cancellation and Timeouts with context
You cannot kill a goroutine from outside. You must cooperate: pass a context.Context and check ctx.Done().
func work(ctx context.Context, id int, out chan<- string) {
for {
select {
case <-ctx.Done():
out <- fmt.Sprintf("worker %d: stopping due to %v", id, ctx.Err())
return
default:
// do work...
}
}
}Use context.WithTimeout for time limits, context.WithCancel for explicit cancellation.
Production Pitfalls We See
- Goroutine leak: a goroutine stuck waiting on a channel that is never closed. Solution: always add a timeout or ctx.Done().
- Race condition: accessing shared variables without a mutex. Run tests with
-race. - Deadlock on unbuffered channel: single producer, but consumer not ready yet — deadlock.
- Copying WaitGroup: always pass a pointer (
*sync.WaitGroup), never the value.
In summary — what to do now
- Write a goroutine today. Take a loop in your code and turn it into goroutines with a WaitGroup.
- Replace shared variables with channels. If two goroutines exchange data, use a channel instead of a shared struct + mutex.
- Add a timeout to every channel read that could block forever.
select + time.After. - Test with
go test -race. The race detector catches unsynchronized accesses. Make it part of your CI. - Read the
net/httpsource code. See how Go uses goroutines for every connection. It's the best tutorial.
Concurrency in Go is a language within the language. Once you master goroutines and channels, you stop thinking about threads and locks. You start thinking in pipelines and actors. That's when your backend scales without paying AWS four times as much.