17.11.2025 • 10 min read

Fire-and-Forget in Go: Building Non-Blocking Tracking Systems

Table of Contents


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:

  1. Receive a bid request
  2. Match targeting criteria
  3. Select the winning ad
  4. 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:

FeatureOS ThreadGoroutine
Memory~1-8MB stack~2KB initial stack (grows dynamically)
Creation time~1-2ms~0.001ms
Context switch~1-10μs~0.2μs
SchedulingOS kernelGo runtime (userspace)
Max concurrentThousandsMillions

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

Last 24 hours

Avg Latency

49ms

Last 12 min

Average Latency Over Time

Tracking request response times (last 12 minutes)

All systems operational
Monitoring active
Last updated: Just now

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:

  1. Use go keyword to spawn goroutines for async work
  2. Always add timeout protection with context.WithTimeout
  3. Log errors but don’t let them crash your application
  4. Monitor success rates and latencies
  5. 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


Built with Go | Published on November 17, 2025