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

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

Pagination

Offset-Based Pagination

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(&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:

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(&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
}
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{})

// 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

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
}
}

// Usage
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"` // 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

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

// 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

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 // 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

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()

// 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

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)

// 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

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) {
// 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 - Implementing secure authentication with JSON Web Tokens.

Go Middleware and Concurrency Patterns JWT Authentication in Go

Comments

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×