Table of Contents
- Introduction
- The Problem: Tracking That Doesn’t Slow You Down
- The Solution: Fire-and-Forget
- Implementation: Building a Production-Ready Tracker
- Understanding Goroutines: Lightweight Concurrency
- Error Handling: Failing Gracefully
- Performance Characteristics
- Advanced Patterns
- Real-World Tradeoffs
- Common Pitfalls and Solutions
- Conclusion
Introduction
In high-performance systems like ad servers, every millisecond counts. When building features that need to send data to external services—like analytics tracking, logging, or metrics—you face a critical decision: should your main application flow wait for these operations to complete?
The answer is often no. Enter the fire-and-forget pattern: a concurrency technique that allows you to trigger background tasks without blocking your main execution flow.
In this article, I’ll walk you through implementing a production-ready fire-and-forget tracking system in Go, exploring goroutines, channels, error handling, and real-world tradeoffs.
The Problem: Tracking That Doesn’t Slow You Down
Imagine you’re building an ad server that must:
- Receive a bid request
- Match targeting criteria
- Select the winning ad
- Return a response to the client
Target latency: < 50ms
But you also need to track every matched line item for analytics. If you make synchronous HTTP calls to your tracking service:
// ❌ BAD: Blocking approach
func HandleBidRequest(bidReq *BidRequest) *BidResponse {
matchedLineItems := findMatches(bidReq) // 10ms
// Tracking blocks the response!
for _, item := range matchedLineItems {
trackLineItem(item.ID) // 100ms per call × 4 items = 400ms!
}
winner := selectWinner(matchedLineItems) // 5ms
return formatResponse(winner) // 5ms
// Total: 420ms (8x slower than target!)
}
Result: Your 50ms target becomes 420ms. Unacceptable.
Key Insight: With blocking approach, operations happen sequentially (420ms total). With fire-and-forget, tracking happens in parallel while the main flow continues (20ms response time).
The Solution: Fire-and-Forget
The fire-and-forget pattern decouples tracking from the main flow:
// ✅ GOOD: Non-blocking approach
func HandleBidRequest(bidReq *BidRequest) *BidResponse {
matchedLineItems := findMatches(bidReq) // 10ms
// Fire tracking events asynchronously
for _, item := range matchedLineItems {
go trackLineItem(item.ID) // Returns instantly!
}
winner := selectWinner(matchedLineItems) // 5ms
return formatResponse(winner) // 5ms
// Total: 20ms (tracking happens in background)
}
Result: 20ms response time. Tracking completes in the background without affecting latency.
Architecture: Main thread handles critical path while spawning goroutines for non-blocking tracking operations.
Implementation: Building a Production-Ready Tracker
Let’s build a robust tracking service step by step.
Step 1: Basic Structure
package services
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"time"
)
type TrackingService struct {
baseURL string
client *http.Client
timeout time.Duration
}
func NewTrackingService(baseURL string, timeout time.Duration) *TrackingService {
return &TrackingService{
baseURL: baseURL,
timeout: timeout,
client: &http.Client{
Timeout: timeout,
},
}
}
Step 2: Fire-and-Forget Method
// FireEvent spawns a goroutine and returns immediately
func (t *TrackingService) FireEvent(attemptID, eventName, resourceID string) {
// Spawn goroutine - this returns in ~0.0001-0.001ms (varies by Go version)
go t.sendEvent(attemptID, eventName, resourceID)
// Caller continues without waiting
}
Key insight: The go keyword spawns a lightweight goroutine (costs ~2KB of stack) and returns immediately to the caller.
Step 3: The Actual HTTP Request (Background)
func (t *TrackingService) sendEvent(attemptID, eventName, resourceID string) {
// Build URL with query parameters
trackingURL := t.buildURL(attemptID, eventName, resourceID)
// Create context with timeout
ctx, cancel := context.WithTimeout(context.Background(), t.timeout)
defer cancel()
// Make HTTP request
req, err := http.NewRequestWithContext(ctx, "GET", trackingURL, nil)
if err != nil {
log.Printf("[ERROR] Failed to create tracking request: %v", err)
return
}
resp, err := t.client.Do(req)
if err != nil {
// Log but don't crash - tracking is non-critical
log.Printf("[WARN] Tracking failed for %s: %v", eventName, err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[WARN] Tracking returned status %d for %s",
resp.StatusCode, eventName)
}
// Success - goroutine exits naturally
}
func (t *TrackingService) buildURL(attemptID, eventName, resourceID string) string {
params := url.Values{}
params.Add("attempt_id", attemptID)
params.Add("event_name", eventName)
params.Add("resource_id", resourceID)
return fmt.Sprintf("%s?%s", t.baseURL, params.Encode())
}
Step 4: Usage in Application
// In your handler
func (h *BidHandler) HandleBid(ctx *gin.Context) {
bidReq := parseBidRequest(ctx)
attemptID := bidReq.ID
// Find all matching line items
matches := h.targetingService.FindMatches(bidReq)
// Fire tracking for each match (async)
for _, lineItem := range matches {
h.trackingService.FireEvent(
attemptID,
"LineItemMatch",
lineItem.ID.Hex(),
)
}
// Main flow continues immediately
winner := h.selectionService.SelectWinner(matches)
response := h.formatResponse(winner)
ctx.JSON(200, response)
}
Understanding Goroutines: Lightweight Concurrency
What Makes Goroutines Special?
Goroutines vs OS Threads:
| Feature | OS Thread | Goroutine |
|---|---|---|
| Memory | ~1-8MB stack | ~2KB initial stack (grows dynamically) |
| Creation time | ~1-2ms | ~0.001ms |
| Context switch | ~1-10μs | ~0.2μs |
| Scheduling | OS kernel | Go runtime (userspace) |
| Max concurrent | Thousands | Millions |
Result: You can spawn thousands of goroutines with minimal overhead.
Key Insight: Y-axis uses logarithmic scale due to massive differences. Goroutines are 4,096× more memory efficient, 2,000× faster to create, and 100,000× more scalable!
🔴 OS Thread
8MB memory
2ms creation time
~10K max concurrent
🟢 Goroutine
2KB memory
0.001ms creation time
~1M max concurrent
Goroutine Lifecycle
func main() {
fmt.Println("Main starts")
go func() {
fmt.Println("Goroutine 1 runs")
time.Sleep(100 * time.Millisecond)
fmt.Println("Goroutine 1 done")
}()
go func() {
fmt.Println("Goroutine 2 runs")
time.Sleep(50 * time.Millisecond)
fmt.Println("Goroutine 2 done")
}()
fmt.Println("Main continues")
time.Sleep(200 * time.Millisecond) // Wait for goroutines
fmt.Println("Main exits")
}
// Output:
// Main starts
// Main continues
// Goroutine 1 runs
// Goroutine 2 runs
// Goroutine 2 done
// Goroutine 1 done
// Main exits
Key points:
- Goroutines run concurrently
- Main function doesn’t wait for them
- If main exits, all goroutines are terminated
Error Handling: Failing Gracefully
The Golden Rule: Never Panic in a Goroutine
// ❌ BAD: Panic in goroutine crashes entire program
go func() {
if err != nil {
panic(err) // This kills your entire server!
}
}()
// ✅ GOOD: Log errors and exit gracefully
go func() {
if err != nil {
log.Printf("[ERROR] Tracking failed: %v", err)
return // Goroutine exits, server continues
}
}()
Timeout Protection
Always use context.WithTimeout to prevent goroutines from hanging:
func (t *TrackingService) sendEvent(attemptID, eventName, resourceID string) {
// Timeout after 500ms
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := t.client.Do(req)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
log.Printf("[WARN] Tracking timeout after 500ms")
} else {
log.Printf("[WARN] Tracking network error: %v", err)
}
return
}
// Handle response...
}
Structured Logging
Production systems need context in logs:
func (t *TrackingService) sendEvent(attemptID, eventName, resourceID string) {
start := time.Now()
// ... make request ...
if err != nil {
log.Printf("[WARN] Tracking failed {event: %s, attempt_id: %s, resource: %s, duration: %v, error: %v}",
eventName, attemptID, resourceID, time.Since(start), err)
return
}
log.Printf("[INFO] Tracking success {event: %s, attempt_id: %s, resource: %s, duration: %v, status: %d}",
eventName, attemptID, resourceID, time.Since(start), resp.StatusCode)
}
Performance Characteristics
Benchmark: Blocking vs Non-Blocking
func BenchmarkBlocking(b *testing.B) {
for i := 0; i < b.N; i++ {
// Simulate 4 tracking calls
for j := 0; j < 4; j++ {
trackingCall() // 100ms each
}
}
}
// Result: 400ms per iteration
func BenchmarkFireAndForget(b *testing.B) {
for i := 0; i < b.N; i++ {
// Simulate 4 tracking calls
for j := 0; j < 4; j++ {
go trackingCall() // Returns immediately
}
}
}
// Result: ~0.004ms per iteration (100,000x faster!)
Key Insight: As tracking calls increase, blocking approach latency grows linearly (red line), while fire-and-forget remains constant at ~0.004ms. At 16 calls, that's a 400,000× speedup!
Blocking
16 calls = 1,600ms
100ms per call × 16
Fire-and-Forget
16 calls = 0.004ms
Non-blocking goroutines
Memory Usage
// Test: Spawn 10,000 goroutines
func TestGoroutineMemory(t *testing.T) {
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(1 * time.Second)
}()
}
wg.Wait()
}
// Memory: ~20MB for 10,000 goroutines
// (vs ~10GB for 10,000 OS threads!)
Dramatic Difference: At 100K concurrent tasks, OS threads consume 100GB while goroutines use only 200MB! That's a 500× improvement.
OS Threads
100K threads = 100GB RAM
~1MB per thread
Goroutines
100K goroutines = 200MB RAM
~2KB per goroutine
Advanced Patterns
Pattern 1: Rate Limiting with Buffered Channels
If you need to limit concurrent tracking requests:
type RateLimitedTracker struct {
baseURL string
client *http.Client
semaphore chan struct{} // Buffered channel as semaphore
}
func NewRateLimitedTracker(baseURL string, maxConcurrent int) *RateLimitedTracker {
return &RateLimitedTracker{
baseURL: baseURL,
client: &http.Client{Timeout: 500 * time.Millisecond},
semaphore: make(chan struct{}, maxConcurrent), // Buffer size = max concurrent
}
}
func (t *RateLimitedTracker) FireEvent(attemptID, eventName, resourceID string) {
go func() {
// Acquire semaphore
t.semaphore <- struct{}{} // Blocks if buffer full
defer func() { <-t.semaphore }() // Release when done
// Make request
t.sendEvent(attemptID, eventName, resourceID)
}()
}
Result: Maximum of maxConcurrent requests run simultaneously.
How it works: The buffered channel acts as a semaphore with 3 slots. First 3 requests acquire slots and execute. The 4th request blocks until a slot becomes available when a goroutine completes and releases its slot.
Pattern 2: Worker Pool
For more control, use a worker pool:
type TrackerPool struct {
workers int
jobQueue chan TrackingJob
client *http.Client
}
type TrackingJob struct {
AttemptID string
EventName string
ResourceID string
}
func NewTrackerPool(workers int, queueSize int) *TrackerPool {
pool := &TrackerPool{
workers: workers,
jobQueue: make(chan TrackingJob, queueSize),
client: &http.Client{Timeout: 500 * time.Millisecond},
}
// Start workers
for i := 0; i < workers; i++ {
go pool.worker()
}
return pool
}
func (p *TrackerPool) worker() {
for job := range p.jobQueue {
p.processJob(job)
}
}
func (p *TrackerPool) FireEvent(attemptID, eventName, resourceID string) {
job := TrackingJob{
AttemptID: attemptID,
EventName: eventName,
ResourceID: resourceID,
}
// Non-blocking send
select {
case p.jobQueue <- job:
// Job queued
default:
// Queue full - drop or log
log.Printf("[WARN] Tracking queue full, dropping event")
}
}
Advantages:
- ✅ Controlled concurrency
- ✅ Visible queue size
- ✅ Graceful degradation when overloaded
Real-World Tradeoffs
When to Use Fire-and-Forget
✅ Good for:
- Analytics tracking
- Metrics collection
- Non-critical logging
- Event notifications
- Audit trails (if eventual consistency is OK)
❌ Not good for:
- Financial transactions
- User authentication
- Critical data validation
- Operations requiring confirmation
Monitoring Considerations
Track these metrics:
type TrackingMetrics struct {
TotalFired int64 // Incremented when FireEvent called
TotalSuccess int64 // Incremented on successful HTTP response
TotalFailed int64 // Incremented on error
TotalTimeout int64 // Incremented on timeout
AvgLatencyMs float64
}
func (t *TrackingService) sendEvent(attemptID, eventName, resourceID string) {
atomic.AddInt64(&t.metrics.TotalFired, 1)
start := time.Now()
// ... make request ...
latency := time.Since(start).Milliseconds()
if err != nil {
atomic.AddInt64(&t.metrics.TotalFailed, 1)
if ctx.Err() == context.DeadlineExceeded {
atomic.AddInt64(&t.metrics.TotalTimeout, 1)
}
} else {
atomic.AddInt64(&t.metrics.TotalSuccess, 1)
}
// Update average latency (simplified)
t.updateAvgLatency(latency)
}
Alert on:
- Success rate < 95%
- Timeout rate > 5%
- Average latency > 300ms
📊 Tracking Service Monitoring Dashboard
Real-time metrics for fire-and-forget tracking operations
Success Rate
97.8%
✓ Healthy
Target: ≥95%
Timeout Rate
2.1%
✓ Healthy
Target: <5%
Total Requests
1,234,567
Avg Latency
49ms
Average Latency Over Time
Tracking request response times (last 12 minutes)
Common Pitfalls and Solutions
Pitfall 1: Goroutine Leaks
Problem:
// ❌ BAD: Goroutine never exits
go func() {
for {
select {
case job := <-jobChan:
process(job)
}
}
}() // No way to stop this!
Solution:
// ✅ GOOD: Use context for cancellation
func (t *Tracker) Start(ctx context.Context) {
go func() {
for {
select {
case job := <-t.jobChan:
t.process(job)
case <-ctx.Done():
return // Graceful exit
}
}
}()
}
Pitfall 2: Race Conditions
Problem:
// ❌ BAD: Concurrent writes without synchronization
type Tracker struct {
successCount int
}
func (t *Tracker) FireEvent() {
go func() {
// ...
t.successCount++ // Race condition!
}()
}
Solution:
// ✅ GOOD: Use atomic operations
import "sync/atomic"
type Tracker struct {
successCount int64
}
func (t *Tracker) FireEvent() {
go func() {
// ...
atomic.AddInt64(&t.successCount, 1) // Thread-safe
}()
}
Pitfall 3: Not Handling Context Cancellation
Problem:
// ❌ BAD: Request might outlive parent context
func (t *Tracker) sendEvent(parentCtx context.Context) {
go func() {
// Uses context.Background() - ignores parent cancellation
ctx := context.Background()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
t.client.Do(req)
}()
}
Solution:
// ✅ GOOD: Create derived context with timeout
func (t *Tracker) sendEvent(parentCtx context.Context) {
go func() {
// Respects parent context cancellation
ctx, cancel := context.WithTimeout(parentCtx, 500*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
t.client.Do(req)
}()
}
Conclusion
The fire-and-forget pattern is a powerful tool for building responsive systems that need to perform non-critical background operations. By leveraging Go’s lightweight goroutines, you can:
- ✅ Maintain low latency for critical paths
- ✅ Handle high concurrency with minimal resources
- ✅ Fail gracefully without affecting user experience
- ✅ Scale to handle thousands of concurrent operations
Key takeaways:
- Use
gokeyword to spawn goroutines for async work - Always add timeout protection with
context.WithTimeout - Log errors but don’t let them crash your application
- Monitor success rates and latencies
- Consider rate limiting or worker pools for production systems
When not to use:
- Critical operations requiring confirmation
- Financial transactions
- Operations where data loss is unacceptable
Additional Resources
- Go Concurrency Patterns: https://go.dev/blog/pipelines
- Context Package: https://pkg.go.dev/context
- Goroutine Documentation: https://go.dev/tour/concurrency/1
Built with Go | Published on November 17, 2025