I built an event analytics backend to track user interactions (clicks, views, scrolls) in real time. What started as a simple CRUD API evolved into a system capable of processing 73 million events per day on a single machine.

This blog walks you through the mistakes I made, how I fixed them, and how I optimized the system.

Tech Stack: Go, PostgreSQL, Redis Streams, Docker, Kubernetes

I wanted to build a backend system that:

  • Accepts event data (user_id, action, element, timestamp)
  • Stores events for analytics
  • Handles high throughput without blocking users
  • Maintains data integrity

Sounds simple, right? That’s what I thought—until scalability became a problem.

Let’s go through the mistakes I made and how I fixed them.

Mistake 1: Synchronous, Blocking Architecture

The Problem

Every HTTP request waited for the database write to finish. Under load, this caused cascading failures:

Request 1 → DB (50ms) Request 2 → DB (50ms) Request 3 → DB busy (200ms) Request 4 → DB overwhelmed (1000ms) Request 5 → Timeout → FAIL

Database Overload

func GetEvent(c *gin.Context) {
    var event models.Event
    c.ShouldBind(&event)
    
    // Write directly to database
    database.AddToDatabase(event)
    
    c.JSON(200, gin.H{"event": event})
}

Fix 1: Asynchronous Processing Using Redis Streams

The API doesn’t need to wait for the database. Users only need confirmation that the event was received.

I chose Redis Streams because:

  • It’s lightweight and fast
  • It keeps message history
  • It guarantees delivery
  • It’s easy to work with
  • It’s RAM-based, so it’s much faster than Kafka

Kafka stores more data, but I didn’t need long-term retention—I needed speed. Redis Streams fit the use case perfectly.

// Handler (fast path)
func GetEvent(c *gin.Context) {
    var event models.Event
    c.ShouldBind(&event)

    // Write to Redis Stream
    database.AddToStream(event)

    // Return immediately
    c.JSON(202, gin.H{"status": "accepted"})
}

// Background Worker
func StartWorker() {
    for {
        events := database.ReadFromGroup()     // Read in batches
        database.BatchAddToDatabase(events)     // Single transaction
        database.AckMessage(events)
        time.Sleep(100 * time.Millisecond)
    }
}

Why This Works

  • Non-blocking API — returns in 5–10ms regardless of DB load
  • Batch processing — 100 inserts in one transaction (10–50x faster)
  • Decoupled layers — API and DB operate independently

Initial Stats

Load LevelRequestsConcurrencyThroughputAvg LatencyFailures
Light1,00010207 req/s46ms0%
Medium5,0005093 req/s524ms0%
Heavy10,00010088 req/s1.1s0%
Extreme20,00020060 req/s3.2s0.7%

Daily Capacity: ~17.8M events

Mistake 2: Misusing Redis Streams

Since it was my first time using Redis Streams, I did something inefficient: I wrote to Redis, then immediately read from Redis in the same request. This removed the benefit of async processing.

Request → Redis Write (5ms) → Redis Read (10ms) → DB Write (500ms) Total: ~515ms (slower than before)

The Fix

Move the worker to a completely separate goroutine:

func main() {
    database.InitRedis()
    database.InitDB()

    go worker.StartWorker()

    router.Run(":8080")
}

Results After True Asynchronous Processing

Load LevelRequestsConcurrencyThroughputAvg LatencyP99Failures
Light1,00010840 req/s11ms56ms0%
Medium5,00050961 req/s51ms114ms0%
Heavy10,000100753 req/s132ms349ms2.7%
Extreme20,00020060 req/s3.2s10.3s99.9%

Daily Capacity: ~73 million events/day

Mistake 3: Not Optimizing Database Operations

Even though the system was fast, the database was constantly under pressure.

Why? 100 events = 100 separate INSERTs → 100 transactions.

Fix: Batch Inserts

One batch insert reduces overhead and improves database health.

Second Optimization: Event Aggregation

Storing every single event leads to:

  • Massive tables
  • Slow queries
  • High storage cost

Example: 1,000 users click the same button → 1,000 rows.

Solution: Time-Window Aggregation

Before:

5 clicks at slightly different timestamps → 5 rows

After:

Group them by a 5–10 second window:

aggregated_events: {action: "click", element: "button", count: 5, window: "14:00:00"}

user_event_maps: Links each user to the aggregated row

This reduces redundant event storage by 90%+.

Final Results

VersionArchitectureThroughputLatencyDaily CapacityImprovement
V1: SyncDirect DB writes207 req/s46ms17.8MBaseline
V2: Async (Wrong)Redis + read-back93 req/s524ms8MWorse
V3: Async (Correct)Redis + Worker840 req/s11ms73M4x better
V4: + AggregationWindow grouping850 req/s11ms73MSame speed, 90% less DB load

Key Metrics (Single Optimized Server)

  • Peak Throughput: 961 req/s
  • Sustained Average: 850 req/s
  • Avg Latency: 11ms
  • P99 Latency: 56ms
  • Daily Capacity: 73,440,000 events
  • Failure Rate: 0% under normal load

Lessons Learned

  1. Architecture > Tools Redis didn’t speed things up until I used it the right way.

  2. Measure Everything Load testing exposes bottlenecks you won’t see in normal use.

  3. Know Your Bottlenecks First bottleneck: synchronous DB writes After fix: DB connection pool Final bottleneck: hardware limits Optimizing the wrong layer wastes effort.

  4. Batch Operations Are Extremely Powerful One transaction with 100 events is 10–50x faster than 100 individual transactions.

  5. Local Testing Isn’t Realistic My single machine handled 850 req/s. Production hardware could easily handle 10x more.

Final Architecture