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 flowchart LR
subgraph Problem["Without Scaling"]
R1[Request] --> DB1[(DB)]
DB1 --> |1M rows| S1[Slow Response]
S1 --> |Timeout| F1[Failure]
end
subgraph Solution["With Scaling"]
R2[Request] --> C[Cache]
C --> |Hit| Fast[Fast Response]
C --> |Miss| DB2[(DB)]
DB2 --> |100 rows| P[Paginated]
P --> Fast
end
style Problem fill:#ffcdd2
style Solution fill:#c8e6c9
Traditional pagination using LIMIT and OFFSET:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 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(¶ms); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error" : err.Error()}) return } offset := (params.Page - 1 ) * params.PageSize var products []Product var total int64 h.db.Model(&Product{}).Count(&total) 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, }) }
More efficient for large datasets:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 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(¶ms) var products []Product query := h.db.Order("id ASC" ).Limit(params.Limit + 1 ) 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] } 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 }
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
style Offset fill:#fff3e0
style Cursor fill:#c8e6c9
Filtering Query Parameter Filtering 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 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{}) 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 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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 } } h.db.Scopes( CategoryScope(filter.Category), PriceRangeScope(filter.MinPrice, filter.MaxPrice), ).Find(&products)
Sorting Dynamic Sorting 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 type SortParams struct { SortBy string `form:"sort_by"` SortOrder string `form:"sort_order"` } 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{}) 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" ) } var products []Product query.Find(&products) c.JSON(http.StatusOK, gin.H{"data" : products}) }
Caching In-Memory Caching 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 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 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 var cache = NewCache()func (h *Handler) GetProduct(c *gin.Context) { id := c.Param("id" ) cacheKey := "product:" + id if cached, found := cache.Get(cacheKey); found { c.JSON(http.StatusOK, gin.H{"data" : cached, "source" : "cache" }) return } 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.Set(cacheKey, product, 5 *time.Minute) c.JSON(http.StatusOK, gin.H{"data" : product, "source" : "database" }) } func (h *Handler) UpdateProduct(c *gin.Context) { id := c.Param("id" ) cache.Delete("product:" + id) }
Redis Caching 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 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) }
flowchart TD
R[Request] --> C{Cache?}
C -->|Hit| H1[Return Cached]
C -->|Miss| DB[(Database)]
DB --> S[Store in Cache]
S --> H2[Return Fresh]
style C fill:#fff3e0
style H1 fill:#c8e6c9
Rate Limiting Token Bucket Algorithm 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 import ( "sync" "time" ) type RateLimiter struct { rate float64 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 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 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 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() 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() if !limiter.GetLimiter(clientID).Allow() { c.JSON(http.StatusTooManyRequests, gin.H{ "error" : "Rate limit exceeded" , }) c.Abort() return } c.Next() } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 func RateLimitMiddleware (limiter *ClientRateLimiter) gin.HandlerFunc { return func (c *gin.Context) { clientID := c.ClientIP() rl := limiter.GetLimiter(clientID) 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 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
style LB fill:#e3f2fd
Health Check Endpoint 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 func (h *Handler) HealthCheck(c *gin.Context) { sqlDB, _ := h.db.DB() if err := sqlDB.Ping(); err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "status" : "unhealthy" , "database" : "down" , }) return } 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" , }) }
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 - Implementing secure authentication with JSON Web Tokens.
Comments