Concurrent HTTP Requests in Golang: Best Practices and Techniques

Rafet Topcu
Insider Engineering
10 min readJan 3, 2024

In the realm of Golang, sending HTTP requests concurrently is a vital skill for optimizing web applications. This article explores various methods to achieve this, from basic goroutines to advanced techniques involving channels and sync.WaitGroup. We’ll delve into best practices for performance and error handling in concurrent environments, equipping you with strategies to enhance the speed and reliability of your Go applications. Let’s dive into the world of concurrent HTTP requests in Golang!

Basic Approach Using Goroutines

When it comes to implementing concurrency in Golang, the most straightforward approach is using goroutines. These are the building blocks of concurrency in Go, offering a simple yet powerful way to execute functions concurrently.

Getting Started with Goroutines

To start a goroutine, simply prefix a function call with the go keyword. This launches the function as a goroutine, allowing the main program to continue running independently. It's like starting a task and moving on without waiting for it to finish.

For instance, consider a scenario where you’re sending an HTTP request. Normally, you’d call a function like sendRequest(), and your program would wait until this function is complete. With goroutines, you can do this concurrently:

go sendRequest("http://example.com")

Handling Multiple Requests

Imagine you have a list of URLs, and you need to send an HTTP request to each. Without goroutines, your program would send these requests one after the other, which is time-consuming. With goroutines, you can send them all at almost the same time:

urls := []string{"http://example.com", "http://another.com", ...}
for _, url := range urls {
go sendRequest(url)
}

This loop starts a new goroutine for each URL, drastically reducing the time your program takes to send all the requests.

Approaches to Concurrent HTTP Requests

In this section, we’ll delve into various methods to handle HTTP requests concurrently in Go. Each approach has its unique characteristics, and understanding these can help you choose the right method for your specific needs.

I’m going to use our insrequester package, our open-source requester, to handle the HTTP requests that I mentioned in this article; you can check it out.

Basic Goroutines

The simplest way to send HTTP requests concurrently in Go is by using goroutines. Goroutines are lightweight threads managed by the Go runtime. Here’s a basic example:

requester := insrequester.NewRequester().Load()

urls := []string{"http://example.com", "http://example.org", "http://example.net"}
for _, url := range urls {
go requester.Get(insrequester.RequestEntity{Endpoint: url})
}

time.Sleep(2 * time.Second) // Wait for goroutines to finish

This method is straightforward but lacks control over goroutines once they are launched. You can’t get the return value of the Get method in this way. You need to sleep for approximately some time to wait for all the goroutines. Even if you call sleep, you may still not be sure that they are finished.

WaitGroups

To improve upon basic goroutines, sync.WaitGroup can be used for better synchronization. It waits for a collection of goroutines to finish executing:

  requester := insrequester.NewRequester().Load()
wg := sync.WaitGroup{}

urls := []string{"http://example.com", "http://example.org", "http://example.net"}
wg.Add(len(urls))

for _, url := range urls {
go requester.Get(insrequester.RequestEntity{Endpoint: url})
}

wg.Wait() // Wait for all goroutines to complete

This ensures that the main function waits for all the HTTP requests to complete.

Channels

Channels are a powerful feature in Go for communication between goroutines. They can be used to collect data from multiple HTTP requests:

requester := insrequester.NewRequester().Load()

urls := []string{"http://example.com", "http://example.org", "http://example.net"}
ch := make(chan string, len(urls))

for _, url := range urls {
go func() {
res, _ := requester.Get(insrequester.RequestEntity{Endpoint: url})
ch <- fmt.Sprintf("%s: %d", url, res.StatusCode)
}()
}

for range urls {
response := <-ch
fmt.Println(response)
}

Channels not only synchronize goroutines but also facilitate the passing of data between them.

Worker Pools

A worker pool is a pattern where a fixed number of workers (goroutines) are created to handle a variable number of tasks. This helps in limiting the number of concurrent HTTP requests, thereby preventing resource exhaustion.

Here’s how you can implement a worker pool in Go:

type Job struct {
URL string
}

func worker(requester *insrequester.Request, jobs <-chan Job, results chan<- *http.Response, wg *sync.WaitGroup) {
for job := range jobs {
res, _ := requester.Get(insrequester.RequestEntity{Endpoint: job.URL})
results <- res
wg.Done()
}
}

func main() {
requester := insrequester.NewRequester().Load()

urls := []string{"http://example.com", "http://example.org", "http://example.net"}
numWorkers := 2 // Define the number of workers in the pool

jobs := make(chan Job, len(urls))
results := make(chan *http.Response, len(urls))
var wg sync.WaitGroup

// Start workers
for w := 0; w < numWorkers; w++ {
go worker(requester, jobs, results, &wg)
}

// Sending jobs to the worker pool
wg.Add(len(urls))
for _, url := range urls {
jobs <- Job{URL: url}
}
close(jobs)
wg.Wait()

// Collecting results
for i := 0; i < len(urls); i++ {
fmt.Println(<-results)
}
}

Using a worker pool allows you to manage a large number of concurrent HTTP requests efficiently. It’s a scalable solution that can be adjusted based on the workload and system capacity, thereby optimizing resource utilization and improving overall performance.

Limiting Goroutines with Channels

This method uses channels to create a semaphore-like mechanism to limit the number of concurrent goroutines. It’s effective in scenarios where you need to throttle HTTP requests to avoid overwhelming the server or hitting rate limits.

Here’s how you can implement it:

requester := insrequester.NewRequester().Load()

urls := []string{"http://example.com", "http://example.org", "http://example.net"}
maxConcurrency := 2 // Limit the number of concurrent requests

limiter := make(chan struct{}, maxConcurrency)

for _, url := range urls {
limiter <- struct{}{} // Acquire a token. Waits here for token releases from the limiter.
go func(url string) {
defer func() { <-limiter }() // Release the token
requester.Post(insrequester.RequestEntity{Endpoint: url})
}(url)
}

// Wait for all goroutines to complete
for i := 0; i < cap(limiter); i++ {
limiter <- struct{}{}
}

The use of deferring in this context is crucial. If you place the <-limiter statement after the Post method and the Post method triggers panic or a similar exception, the <-limiter line will not be executed. This can lead to infinite waits, as the semaphore token is never released, ultimately resulting in timeout issues.

Limiting Goroutines with Semaphore

The sync/semaphore package offers a clean and efficient way to limit the number of goroutines running concurrently. This approach is particularly useful when you want to manage resource allocation more systematically.

requester := insrequester.NewRequester().Load()

urls := []string{"http://example.com", "http://example.org", "http://example.net"}
maxConcurrency := int64(2) // Set the maximum number of concurrent requests

sem := semaphore.NewWeighted(maxConcurrency)
ctx := context.Background()

for _, url := range urls {
// Acquire a semaphore weight before starting a goroutine
if err := sem.Acquire(ctx, 1); err != nil {
fmt.Printf("Failed to acquire semaphore: %v\n", err)
continue
}

go func(url string) {
defer sem.Release(1) // Release the semaphore weight on completion
res, _ := requester.Get(insrequester.RequestEntity{Endpoint: url})
fmt.Printf("%s: %d\n", url, res.StatusCode)
}(url)
}

// Wait for all goroutines to release their semaphore weights
if err := sem.Acquire(ctx, maxConcurrency); err != nil {
fmt.Printf("Failed to acquire semaphore while waiting: %v\n", err)
}

This approach, using the semaphore package, offers a more structured and readable way of handling concurrency compared to manually managing channels. It’s particularly beneficial when dealing with complex synchronization requirements or when you need more granular control over the concurrency levels.

So, What is the Best Way?

After exploring various approaches to handling concurrent HTTP requests in Go, the question arises: What is the best way to do it? The answer, as often is the case in software engineering, depends on the specific requirements and constraints of your application. Let’s consider the key factors to determine the most suitable approach:

Assessing Your Needs

  • Scale of Requests: If you’re dealing with a high volume of requests, a worker pool or semaphore-based approach provides better control over resource usage.
  • Error Handling: If robust error handling is crucial, using channels or the semaphore package can offer more structured error management.
  • Rate Limiting: For applications that need to respect rate limits, limiting goroutines with channels or the semaphore package can be effective.
  • Complexity and Maintainability: Consider the complexity of each approach. While channels offer more control, they also add complexity. The semaphore package, on the other hand, provides a more straightforward solution.

Error Handling

Error handling in goroutines is a tricky topic due to the nature of concurrent execution in Go. Since goroutines run independently, managing and propagating errors can be challenging but is crucial for building robust applications. Below are some strategies to effectively handle errors in concurrent Go programs:

Centralized Error Channel

One common approach is to use a centralized error channel through which all goroutines can send their errors. The main goroutine can then listen to this channel and take appropriate action.

func worker(errChan chan<- error) {
// Perform task
if err := doTask(); err != nil {
errChan <- err // Send any errors to the error channel
}
}

func main() {
errChan := make(chan error, 1) // Buffered channel for errors

go worker(errChan)

if err := <-errChan; err != nil {
// Handle error
log.Printf("Error occurred: %v", err)
}
}

Or you can listen to the errChan in a different goroutine.


func worker(errChan chan<- error, job Job) {
// Perform task
if err := doTask(job); err != nil {
errChan <- err // Send any errors to the error channel
}
}

func listenErrors(done chan struct{}, errChan <-chan error) {
for {
select {
case err := <-errChan:
// Handle error
case <-done:
return
}
}
}

func main() {
errChan := make(chan error, 1000) // Channel for errors
done := make(chan struct{}) // Channel to signal goroutine to stop

go listenErrors(done, errChan)

for _, job := range jobs {
go worker(errChan, job)
}

// wait for all goroutines to complete somehow
done <- struct{}{} // Signal goroutine to stop listening for errors
}

Error Group

The golang.org/x/sync/errgroup package provides a convenient way to group multiple goroutines and handle any errors they produce. An errgroup.Group ensures that once an error occurs in any goroutine, all subsequent operations are canceled.

import "golang.org/x/sync/errgroup"

func main() {
g, ctx := errgroup.WithContext(context.Background())

urls := []string{"http://example.com", "http://example.org"}
for _, url := range urls {
// Launch a goroutine for each URL
g.Go(func() error {
// Replace with actual HTTP request logic
_, err := fetchURL(ctx, url)
return err
})
}

// Wait for all requests to complete
if err := g.Wait(); err != nil {
log.Printf("Error occurred: %v", err)
}
}

This approach simplifies error handling, especially when dealing with a large number of goroutines.

Wrapping Goroutines

Another strategy is to wrap each goroutine in a function that handles its errors. This encapsulation can include recovery from panics or other error management logic.

func work() error {
// Do some work
return err
}

func main() {
go func() {
err := work()
if err != nil {
// Handle error
}
}()

// Wait for the work to be done somehow
}

In summary, the choice of error-handling strategy in Go’s concurrent programming depends on the specific requirements and context of your application. Whether it’s through centralized error channels, dedicated error-handling goroutines, the use of error groups, or wrapping goroutines in error-managing functions, each method offers its own set of benefits and trade-offs.

Conclusion

In conclusion, this article has explored various approaches to sending HTTP requests concurrently in Golang, a crucial skill for optimizing web applications. We’ve discussed basic goroutines, sync.WaitGroup, channels, worker pools, and methods for limiting goroutines. Each approach has its unique characteristics and can be chosen based on specific application requirements.

Furthermore, the article has highlighted the importance of error handling in concurrent Go programs. Managing errors in a concurrent environment can be challenging but is essential for building robust applications. Strategies such as using centralized error channels, the errgroup package, or wrapping goroutines with error handling logic have been discussed to help developers effectively handle errors.

Ultimately, the choice of the best approach for handling concurrent HTTP requests in Go depends on factors like the scale of requests, error handling requirements, rate limiting, and overall complexity and maintainability of the code. Developers should carefully consider these factors when implementing concurrent features in their applications.

I hope you enjoyed this article. If you have any questions, please feel free to contact me on LinkedIn or comment below.

Follow us on the Insider Engineering Blog to read more about our AWS solutions at scale and engineering stories. Here are more stories you may enjoy.

--

--