---
title: "Scaling Go APIs: Pagination, Caching, and Rate Limiting"
url: "https://vanducng.dev/2024/04/12/Scaling-Go-APIs-Pagination-Caching-Rate-Limiting/"
date: "2024-04-12"
series: "go-backend"
categories: ["Backend"]
tags: ["go","api"]
---

# Scaling Go APIs: Pagination, Caching, and Rate Limiting

As your API grows, handling thousands of records and requests requires thoughtful scaling strategies. This post covers essential techniques for scaling Go APIs: pagination for large datasets, caching for performance, filtering and sorting for flexibility, and rate limiting for protection.

## Why Scaling Matters

```mermaid
flowchart TB
    subgraph Solution["With Scaling"]
        direction LR
        R2[Request] --> C[Cache]
        C --> |Hit| Fast[Fast Response]
        C --> |Miss| DB2[(DB)]
        DB2 --> |100 rows| P[Paginated]
        P --> Fast
    end

    subgraph Problem["Without Scaling"]
        direction LR
        R1[Request] --> DB1[(DB)]
        DB1 --> |1M rows| S1[Slow Response]
        S1 --> |Timeout| F1[Failure]
    end

    classDef pinkClass fill:#E74C3C,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff

    class Problem pinkClass
    class Solution greenClass
```

## Pagination

### Offset-Based Pagination

Traditional pagination using LIMIT and OFFSET:

```go
type PaginationParams struct {
    Page     int `form:"page" binding:"min=1"`
    PageSize int `form:"page_size" binding:"min=1,max=100"`
}

type PaginatedResponse struct {
    Data       interface{} `json:"data"`
    Page       int         `json:"page"`
    PageSize   int         `json:"page_size"`
    TotalItems int64       `json:"total_items"`
    TotalPages int         `json:"total_pages"`
}

func (h *Handler) ListProducts(c *gin.Context) {
    params := PaginationParams{Page: 1, PageSize: 10}
    if err := c.ShouldBindQuery(&params); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
        return
    }

    offset := (params.Page - 1) * params.PageSize

    var products []Product
    var total int64

    // Count total
    h.db.Model(&Product{}).Count(&total)

    // Fetch page
    h.db.Offset(offset).Limit(params.PageSize).Find(&products)

    totalPages := int((total + int64(params.PageSize) - 1) / int64(params.PageSize))

    c.JSON(http.StatusOK, PaginatedResponse{
        Data:       products,
        Page:       params.Page,
        PageSize:   params.PageSize,
        TotalItems: total,
        TotalPages: totalPages,
    })
}
```

### Cursor-Based Pagination

More efficient for large datasets:

```go
type CursorParams struct {
    Cursor string `form:"cursor"`
    Limit  int    `form:"limit" binding:"min=1,max=100"`
}

type CursorResponse struct {
    Data       interface{} `json:"data"`
    NextCursor string      `json:"next_cursor,omitempty"`
    HasMore    bool        `json:"has_more"`
}

func (h *Handler) ListProductsCursor(c *gin.Context) {
    params := CursorParams{Limit: 10}
    c.ShouldBindQuery(&params)

    var products []Product
    query := h.db.Order("id ASC").Limit(params.Limit + 1) // Fetch one extra

    if params.Cursor != "" {
        cursorID, _ := decodeCursor(params.Cursor)
        query = query.Where("id > ?", cursorID)
    }

    query.Find(&products)

    hasMore := len(products) > params.Limit
    if hasMore {
        products = products[:params.Limit] // Remove extra
    }

    var nextCursor string
    if hasMore && len(products) > 0 {
        nextCursor = encodeCursor(products[len(products)-1].ID)
    }

    c.JSON(http.StatusOK, CursorResponse{
        Data:       products,
        NextCursor: nextCursor,
        HasMore:    hasMore,
    })
}

func encodeCursor(id uint) string {
    return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", id)))
}

func decodeCursor(cursor string) (uint, error) {
    data, err := base64.StdEncoding.DecodeString(cursor)
    if err != nil {
        return 0, err
    }
    id, err := strconv.ParseUint(string(data), 10, 32)
    return uint(id), err
}
```

```mermaid
flowchart TD
    subgraph Offset["Offset Pagination"]
        O1[Page 1: OFFSET 0] --> O2[Page 2: OFFSET 10]
        O2 --> O3[Page 100: OFFSET 990]
        O3 --> |Slow!| S1[Scans 990 rows]
    end

    subgraph Cursor["Cursor Pagination"]
        C1[First: id > 0] --> C2[Next: id > 10]
        C2 --> C3[Next: id > 1000]
        C3 --> |Fast!| S2[Uses index]
    end

    classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff

    class Offset orangeClass
    class Cursor greenClass
```

## Filtering

### Query Parameter Filtering

```go
type ProductFilter struct {
    Category  string  `form:"category"`
    MinPrice  float64 `form:"min_price"`
    MaxPrice  float64 `form:"max_price"`
    InStock   *bool   `form:"in_stock"`
    Search    string  `form:"search"`
}

func (h *Handler) ListProducts(c *gin.Context) {
    var filter ProductFilter
    c.ShouldBindQuery(&filter)

    query := h.db.Model(&Product{})

    // Apply filters
    if filter.Category != "" {
        query = query.Where("category = ?", filter.Category)
    }
    if filter.MinPrice > 0 {
        query = query.Where("price >= ?", filter.MinPrice)
    }
    if filter.MaxPrice > 0 {
        query = query.Where("price <= ?", filter.MaxPrice)
    }
    if filter.InStock != nil {
        if *filter.InStock {
            query = query.Where("stock > 0")
        } else {
            query = query.Where("stock = 0")
        }
    }
    if filter.Search != "" {
        query = query.Where("name ILIKE ?", "%"+filter.Search+"%")
    }

    var products []Product
    query.Find(&products)

    c.JSON(http.StatusOK, gin.H{"data": products})
}
```

### Reusable Filter Scopes

```go
func CategoryScope(category string) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        if category != "" {
            return db.Where("category = ?", category)
        }
        return db
    }
}

func PriceRangeScope(min, max float64) func(db *gorm.DB) *gorm.DB {
    return func(db *gorm.DB) *gorm.DB {
        if min > 0 {
            db = db.Where("price >= ?", min)
        }
        if max > 0 {
            db = db.Where("price <= ?", max)
        }
        return db
    }
}

// Usage
h.db.Scopes(
    CategoryScope(filter.Category),
    PriceRangeScope(filter.MinPrice, filter.MaxPrice),
).Find(&products)
```

## Sorting

### Dynamic Sorting

```go
type SortParams struct {
    SortBy    string `form:"sort_by"`
    SortOrder string `form:"sort_order"` // asc or desc
}

var allowedSortFields = map[string]bool{
    "name":       true,
    "price":      true,
    "created_at": true,
    "stock":      true,
}

func (h *Handler) ListProducts(c *gin.Context) {
    var sort SortParams
    c.ShouldBindQuery(&sort)

    query := h.db.Model(&Product{})

    // Validate and apply sorting
    if sort.SortBy != "" && allowedSortFields[sort.SortBy] {
        order := "ASC"
        if strings.ToUpper(sort.SortOrder) == "DESC" {
            order = "DESC"
        }
        query = query.Order(fmt.Sprintf("%s %s", sort.SortBy, order))
    } else {
        query = query.Order("created_at DESC") // Default
    }

    var products []Product
    query.Find(&products)

    c.JSON(http.StatusOK, gin.H{"data": products})
}
```

## Caching

### In-Memory Caching

```go
import (
    "sync"
    "time"
)

type CacheItem struct {
    Value      interface{}
    Expiration time.Time
}

type Cache struct {
    items map[string]CacheItem
    mu    sync.RWMutex
}

func NewCache() *Cache {
    cache := &Cache{
        items: make(map[string]CacheItem),
    }
    go cache.cleanupLoop()
    return cache
}

func (c *Cache) Set(key string, value interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.items[key] = CacheItem{
        Value:      value,
        Expiration: time.Now().Add(ttl),
    }
}

func (c *Cache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    item, found := c.items[key]
    if !found || time.Now().After(item.Expiration) {
        return nil, false
    }
    return item.Value, true
}

func (c *Cache) Delete(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.items, key)
}

func (c *Cache) cleanupLoop() {
    ticker := time.NewTicker(time.Minute)
    for range ticker.C {
        c.mu.Lock()
        for key, item := range c.items {
            if time.Now().After(item.Expiration) {
                delete(c.items, key)
            }
        }
        c.mu.Unlock()
    }
}
```

### Using Cache in Handlers

```go
var cache = NewCache()

func (h *Handler) GetProduct(c *gin.Context) {
    id := c.Param("id")
    cacheKey := "product:" + id

    // Check cache
    if cached, found := cache.Get(cacheKey); found {
        c.JSON(http.StatusOK, gin.H{"data": cached, "source": "cache"})
        return
    }

    // Fetch from database
    var product Product
    if err := h.db.First(&product, id).Error; err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "Product not found"})
        return
    }

    // Cache for 5 minutes
    cache.Set(cacheKey, product, 5*time.Minute)

    c.JSON(http.StatusOK, gin.H{"data": product, "source": "database"})
}

// Invalidate cache on update
func (h *Handler) UpdateProduct(c *gin.Context) {
    id := c.Param("id")

    // ... update logic ...

    // Invalidate cache
    cache.Delete("product:" + id)
}
```

### Redis Caching

```go
import (
    "context"
    "encoding/json"
    "time"

    "github.com/redis/go-redis/v9"
)

type RedisCache struct {
    client *redis.Client
}

func NewRedisCache(addr string) *RedisCache {
    client := redis.NewClient(&redis.Options{
        Addr: addr,
    })
    return &RedisCache{client: client}
}

func (c *RedisCache) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
    data, err := json.Marshal(value)
    if err != nil {
        return err
    }
    return c.client.Set(ctx, key, data, ttl).Err()
}

func (c *RedisCache) Get(ctx context.Context, key string, dest interface{}) error {
    data, err := c.client.Get(ctx, key).Bytes()
    if err != nil {
        return err
    }
    return json.Unmarshal(data, dest)
}
```

```mermaid
flowchart TD
    R[Request] --> C{Cache?}
    C -->|Hit| H1[Return Cached]
    C -->|Miss| DB[(Database)]
    DB --> S[Store in Cache]
    S --> H2[Return Fresh]

    classDef orangeClass fill:#F39C12,stroke:#333,stroke-width:2px,color:#fff
    classDef greenClass fill:#27AE60,stroke:#333,stroke-width:2px,color:#fff

    class C orangeClass
    class H1 greenClass
```

## Rate Limiting

### Token Bucket Algorithm

```go
import (
    "sync"
    "time"
)

type RateLimiter struct {
    rate       float64 // Tokens per second
    bucketSize float64
    tokens     float64
    lastTime   time.Time
    mu         sync.Mutex
}

func NewRateLimiter(rate float64, bucketSize float64) *RateLimiter {
    return &RateLimiter{
        rate:       rate,
        bucketSize: bucketSize,
        tokens:     bucketSize,
        lastTime:   time.Now(),
    }
}

func (rl *RateLimiter) Allow() bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(rl.lastTime).Seconds()
    rl.lastTime = now

    // Add tokens based on elapsed time
    rl.tokens += elapsed * rl.rate
    if rl.tokens > rl.bucketSize {
        rl.tokens = rl.bucketSize
    }

    if rl.tokens >= 1 {
        rl.tokens--
        return true
    }
    return false
}
```

### Per-Client Rate Limiting

```go
type ClientRateLimiter struct {
    limiters map[string]*RateLimiter
    mu       sync.RWMutex
    rate     float64
    burst    float64
}

func NewClientRateLimiter(rate, burst float64) *ClientRateLimiter {
    return &ClientRateLimiter{
        limiters: make(map[string]*RateLimiter),
        rate:     rate,
        burst:    burst,
    }
}

func (crl *ClientRateLimiter) GetLimiter(clientID string) *RateLimiter {
    crl.mu.RLock()
    limiter, exists := crl.limiters[clientID]
    crl.mu.RUnlock()

    if exists {
        return limiter
    }

    crl.mu.Lock()
    defer crl.mu.Unlock()

    // Double-check after acquiring write lock
    if limiter, exists = crl.limiters[clientID]; exists {
        return limiter
    }

    limiter = NewRateLimiter(crl.rate, crl.burst)
    crl.limiters[clientID] = limiter
    return limiter
}

func RateLimitMiddleware(limiter *ClientRateLimiter) gin.HandlerFunc {
    return func(c *gin.Context) {
        clientID := c.ClientIP() // Or use API key, user ID, etc.

        if !limiter.GetLimiter(clientID).Allow() {
            c.JSON(http.StatusTooManyRequests, gin.H{
                "error": "Rate limit exceeded",
            })
            c.Abort()
            return
        }

        c.Next()
    }
}
```

### Rate Limit Headers

```go
func RateLimitMiddleware(limiter *ClientRateLimiter) gin.HandlerFunc {
    return func(c *gin.Context) {
        clientID := c.ClientIP()
        rl := limiter.GetLimiter(clientID)

        // Add rate limit headers
        c.Header("X-RateLimit-Limit", fmt.Sprintf("%.0f", rl.bucketSize))
        c.Header("X-RateLimit-Remaining", fmt.Sprintf("%.0f", rl.tokens))

        if !rl.Allow() {
            c.Header("Retry-After", "1")
            c.JSON(http.StatusTooManyRequests, gin.H{
                "error": "Rate limit exceeded",
            })
            c.Abort()
            return
        }

        c.Next()
    }
}
```

## Load Balancing Concepts

```mermaid
flowchart TD
    LB[Load Balancer]
    LB --> S1[Server 1]
    LB --> S2[Server 2]
    LB --> S3[Server 3]

    S1 --> DB[(Database)]
    S2 --> DB
    S3 --> DB

    S1 --> R[(Redis Cache)]
    S2 --> R
    S3 --> R

    classDef blueClass fill:#4A90E2,stroke:#333,stroke-width:2px,color:#fff

    class LB blueClass
```

### Health Check Endpoint

```go
func (h *Handler) HealthCheck(c *gin.Context) {
    // Check database
    sqlDB, _ := h.db.DB()
    if err := sqlDB.Ping(); err != nil {
        c.JSON(http.StatusServiceUnavailable, gin.H{
            "status":   "unhealthy",
            "database": "down",
        })
        return
    }

    // Check Redis
    if err := h.redis.Ping(c.Request.Context()).Err(); err != nil {
        c.JSON(http.StatusServiceUnavailable, gin.H{
            "status": "unhealthy",
            "redis":  "down",
        })
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "status":   "healthy",
        "database": "up",
        "redis":    "up",
    })
}
```

## Performance Summary

| Technique | Benefit | When to Use |
|-----------|---------|-------------|
| **Pagination** | Limit response size | Large datasets |
| **Cursor Pagination** | Consistent performance | Very large datasets, real-time |
| **Filtering** | Reduce data transfer | User-specific queries |
| **Caching** | Reduce DB load | Frequently accessed data |
| **Rate Limiting** | Protect resources | Public APIs |
| **Load Balancing** | Horizontal scaling | High traffic |

---

*Next post: [JWT Authentication in Go](https://vanducng.dev/2024/04/19/JWT-Authentication-in-Go/) - Implementing secure authentication with JSON Web Tokens.*
