The checkout service hangs
Your checkout service receives an order. It needs to ask the inventory service if there are enough widgets in stock. The inventory service is running on a different machine, maybe in a different data center. You send a request. The inventory service is slow. Your checkout service waits. The user stares at a loading spinner. The wait time grows. Eventually, the browser gives up, or the load balancer kills the connection, or your checkout service runs out of goroutines because every request is stuck waiting for inventory.
The problem is rarely the code that sends the request. The problem is treating the network like a local function call. Local calls return instantly. Network calls can hang, drop packets, return partial data, or fail silently. Go gives you the tools to handle the chaos, but you have to use them. Inter-service communication in Go relies on net/http for standard web protocols and gRPC for high-performance internal RPC. The patterns are the same: set timeouts, propagate context, handle errors explicitly, and never leak resources.
The network is not a function call
Think of calling another service like making a phone call. You dial the number. The phone rings. Maybe someone answers. Maybe it rings forever. Maybe the line drops. Maybe you get a busy signal. You need a plan for each outcome.
In Go, net/http is the standard library for HTTP communication. It handles DNS lookups, TCP connections, TLS handshakes, and request/response parsing. The http.Client type is your phone. It manages the connection pool, so you don't have to reinvent the wheel. The client reuses connections to the same host to save latency. This is a performance feature, but it comes with a responsibility: you must return connections to the pool when you are done.
The mechanism for returning a connection is closing the response body. The http.Client tracks open bodies. When you close the body, the client knows the connection is free. If you forget to close the body, the connection stays checked out. The pool fills up. New requests block. Your service degrades until it stops responding entirely.
Context is the other half of the equation. A context.Context carries deadlines, cancellation signals, and request-scoped values. When you call a downstream service, you pass the context along. If the original client cancels the request, the context signals cancellation. Your handler stops work, and the downstream request aborts. This prevents wasted work and frees resources quickly. Context is plumbing. Run it through every long-lived call site.
Minimal client setup
Here is the simplest way to make a request that respects timeouts and cancellation. The code creates a client, sets a timeout, builds a request with a context, sends it, and closes the body.
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func main() {
// Create a client with a timeout to avoid hanging forever on slow servers.
client := &http.Client{Timeout: 5 * time.Second}
// Context carries the deadline and cancellation signal for this operation.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// NewRequestWithContext binds the context to the request lifecycle.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://inventory:8080/items/42", nil)
if err != nil {
fmt.Println("bad request:", err)
return
}
// Do sends the request and waits for the response or a network error.
resp, err := client.Do(req)
if err != nil {
fmt.Println("network error:", err)
return
}
// Close the body to return the connection to the client's pool.
defer resp.Body.Close()
fmt.Println("status:", resp.StatusCode)
}
The client timeout and the context timeout work together. The client timeout is a hard limit on the entire request, including DNS and connection setup. The context timeout is a deadline that can be shorter. If the context expires, the request aborts immediately, even if the client timeout has not elapsed. This gives you fine-grained control over latency.
The defer cancel() call ensures the context is cleaned up when the function returns. This releases any resources associated with the context and signals to the runtime that the deadline is no longer relevant. Always cancel a context when you create it with WithTimeout or WithCancel.
How the request flows
When client.Do(req) runs, several things happen under the hood. The client checks its connection pool for an existing connection to inventory:8080. If it finds one, it reuses it. If not, it performs a DNS lookup, opens a TCP socket, and negotiates TLS if the URL uses HTTPS. Then it writes the HTTP request to the socket and waits for the response.
While waiting, the goroutine is blocked. It is not consuming CPU, but it is holding a stack and a goroutine ID. Go creates goroutines cheaply, but they are not free. If you spawn a goroutine for every request and each goroutine waits for a slow service, you will run out of memory or hit scheduler limits. The timeout protects you here. If the response does not arrive in time, the request fails, the goroutine returns, and the resources are freed.
The response object contains the status code, headers, and a Body reader. The body is an io.ReadCloser. You must read the body and close it. Closing the body is what returns the connection to the pool. If you return early without closing the body, the connection leaks. The defer resp.Body.Close() pattern handles this, but only if resp is not nil. The code checks err before accessing resp, so resp is guaranteed to be non-nil when the defer runs.
Convention aside: gofmt formats this code consistently. The indentation, spacing, and import grouping are decided by the tool. Do not argue about formatting. Let gofmt decide. Most editors run gofmt on save. This keeps the codebase uniform and reduces noise in code reviews.
Realistic handler with error handling
In production, you rarely call client.Do directly in main. You wrap the logic in a function that handles JSON decoding, error wrapping, and context propagation. The function signature follows Go conventions: the context is the first parameter, named ctx. The function returns the result and an error.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// InventoryItem represents the data shape from the upstream service.
type InventoryItem struct {
ID string `json:"id"`
Count int `json:"count"`
}
// CheckInventory calls the inventory service and returns the item count.
// It respects the incoming context for cancellation and deadlines.
func CheckInventory(ctx context.Context, client *http.Client, itemID string) (int, error) {
// Build the request URL with the item ID.
url := fmt.Sprintf("http://inventory:8080/items/%s", itemID)
// Attach context to the request so cancellation propagates upstream.
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, fmt.Errorf("create request: %w", err)
}
// Execute the request and wait for the response.
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("call inventory: %w", err)
}
// Always close the body to return the connection to the pool.
defer resp.Body.Close()
// Check for HTTP errors like 404 or 500.
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("inventory returned status %d", resp.StatusCode)
}
// Decode the JSON response into the struct.
var item InventoryItem
if err := json.NewDecoder(resp.Body).Decode(&item); err != nil {
return 0, fmt.Errorf("decode response: %w", err)
}
return item.Count, nil
}
The function uses fmt.Errorf with %w to wrap errors. This preserves the original error so callers can inspect it later. The error message adds context: call inventory: connection refused is more helpful than just connection refused. This pattern is standard in Go. The community accepts the verbosity of if err != nil because it makes the unhappy path visible. You cannot accidentally ignore an error.
The function accepts *http.Client as a parameter. This allows the caller to configure the client with timeouts, transport settings, or middleware. The convention is to create the client once at startup and pass it around. Do not create a new client in every request. Creating a client allocates a transport and a connection pool. Reusing the client reuses the pool, which improves performance and reduces resource usage.
Convention aside: Receiver naming matters in Go. If this were a method on a struct, the receiver name should be one or two letters matching the type, like (s *Service) CheckInventory(...). Do not use this or self. The language does not require a receiver name, but the convention improves readability.
Pitfalls and compiler traps
Inter-service communication has specific failure modes. The compiler catches some, but runtime bugs are common.
If you try to pass a context to http.Get, the compiler rejects the code. http.Get does not accept a context. The compiler complains with too many arguments in call to http.Get. You must use http.NewRequestWithContext and client.Do to attach a context. This is intentional. http.Get is a convenience function for simple scripts. Production code needs control over context and timeouts.
Forgetting to close the response body causes connection leaks. The compiler does not catch this. The program compiles and runs. Over time, the connection pool fills up. Requests start blocking. The service degrades. Always use defer resp.Body.Close() immediately after checking that resp is not nil.
Ignoring the context leads to goroutine leaks. If a request is cancelled but the downstream call continues, the goroutine waits until the downstream service times out. If the downstream service never times out, the goroutine leaks forever. The worst goroutine bug is the one that never logs. Propagate the context to every call that might block.
Passing a *string for parameters is unnecessary. Strings are cheap to pass by value. They are immutable and small. Passing a pointer adds indirection without benefit. Use string for parameters. The compiler will optimize the pass-by-value efficiently.
If you forget to import a package, the compiler rejects the build with undefined: pkg. If you import a package and do not use it, the compiler rejects the build with imported and not used. Go enforces clean imports. Remove unused imports immediately. This keeps the dependency graph tight and reduces build times.
Choosing your transport
Go supports multiple ways to talk to other services. The right choice depends on your requirements.
Use net/http with JSON when you need broad compatibility with web services, browsers, or third-party APIs that speak standard HTTP. HTTP is universal. Every language can speak it. JSON is human-readable and flexible. This is the default choice for most inter-service communication.
Use gRPC when you control both services and need high performance with strongly typed contracts and streaming support. gRPC uses Protocol Buffers for serialization, which is faster and smaller than JSON. It supports bidirectional streaming and multiplexing over a single connection. The trade-off is complexity. You need a code generator and a protobuf schema. gRPC is harder to debug with standard tools.
Use a message broker like RabbitMQ or Kafka when services must decouple completely and handle bursts of load without blocking each other. Message queues allow asynchronous communication. The producer sends a message and moves on. The consumer processes the message when it is ready. This improves resilience and scalability. The trade-off is operational complexity. You need to manage the broker, handle retries, and ensure message ordering.
Use synchronous calls when the downstream data is required immediately to fulfill the current request, and the latency budget allows waiting. If the user cannot proceed without the data, you must wait. Set strict timeouts and have fallback strategies. If the downstream service is critical, consider caching or circuit breakers to handle failures gracefully.