Golang Time Handling: From Basics to Production Patterns

Golang Time Handling: From Basics to Production Patterns

I found myself staring at logs at 2am last week, trying to figure out why our data cleanup jobs hadn't run in three days. Everything looked fine - the scheduler was running, the code was correct, but nothing was happening. Turns out a third-party API had quietly switched their datetime format from RFC3339 to some custom layout, and our time parsing was failing silently. The cleanup tasks never kicked off, and we had weeks of data piling up that should have been deleted.

This is the kind of thing that drives you mad. You write a scheduler, test it thoroughly, deploy to production, and it works perfectly for months. Then one day an upstream service changes their date format and everything breaks in the most subtle way possible.

I've built schedulers for everything - token limit resets, data cleanup, API health checks, billing calculations. I've implemented rate limiters using time windows, added request timing to HTTP handlers, and created database timeout patterns that actually work when things go wrong. The Go time package is incredibly powerful, but it's also surprisingly easy to get wrong.

The problem isn't the documentation - it's comprehensive. The problem is that most examples show you the basics, not the production patterns that matter. How do you handle schedulers across multiple servers? What happens when your database queries timeout? How do you build a rate limiter that doesn't fall over under load?

Here's what I've learned about working with time in Go, from parsing dates correctly through to building schedulers that run reliably in production. We'll cover the fundamentals, then dive into real examples from systems I've built - data retention schedulers, database monitoring, rate limiting, and the gotchas that will save you hours of debugging.

Go Time Package Fundamentals

The Go time package revolves around three core types that you'll use constantly: time.Timetime.Duration, and timers. Once you understand these properly, everything else becomes much clearer.

Working with time.Time

time.Time represents an instant in time. The key thing to understand is that it's always stored in UTC internally, but it carries timezone information. This is brilliant for avoiding timezone bugs, but it can catch you off guard.

now := time.Now()
fmt.Println(now) // 2024-01-15 14:30:45.123456789 +0000 UTC

// Creating specific times
birthday := time.Date(1990, time.March, 15, 0, 0, 0, 0, time.UTC)

The most common mistake I see is assuming time.Now() gives you local time. It doesn't - it gives you the current time in the system's timezone. If you're running in a Docker container or on a server in a different timezone, this can lead to confusion.

Duration handling that actually works

time.Duration represents a span of time. It's an int64 under the hood, counting nanoseconds. This makes arithmetic operations fast and precise.

// Common durations
fiveMinutes := 5 * time.Minute
halfHour := 30 * time.Minute
oneDay := 24 * time.Hour

// Duration arithmetic
total := fiveMinutes + halfHour
fmt.Println(total) // 35m0s

// Converting to different units
seconds := total.Seconds() // 2100.0
milliseconds := total.Milliseconds() // 2100000

One thing that caught me out early on - you can't multiply durations by floats directly. You have to convert:

// This won't compile
// result := 2.5 * time.Hour

// This works
result := time.Duration(2.5 * float64(time.Hour))

Timers and tickers for scheduling

time.Timer and time.Ticker are your tools for scheduling. Timers fire once, tickers fire repeatedly.

// Timer - fires once after 5 minutes
timer := time.NewTimer(5 * time.Minute)
<-timer.C // blocks until timer fires

// Ticker - fires every 30 seconds
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
    // This runs every 30 seconds
    doPeriodicTask()
}

The critical thing with timers and tickers is cleanup. If you don't stop them properly, you'll leak goroutines:

ticker := time.NewTicker(time.Second)
defer ticker.Stop() // Always stop tickers

// For timers, you might want to stop early
timer := time.NewTimer(5 * time.Minute)
defer timer.Stop()

select {
case <-timer.C:
    // Timer fired normally
case <-ctx.Done():
    // Context cancelled, timer.Stop() called by defer
}

Time Formatting and Parsing in Go

This is probably where most time-related bugs happen. Go's approach to time formatting is unique and surprisingly powerful, but it's also where that 2am debugging session I mentioned earlier came from.

Go's reference time approach

Instead of format codes like %Y-%m-%d, Go uses a reference time: Mon Jan 2 15:04:05 MST 2006. This is Unix time 1136239445, and you format by showing how you want this reference time displayed.

now := time.Now()

// Common formats
fmt.Println(now.Format("2006-01-02 15:04:05")) // 2024-01-15 14:30:45
fmt.Println(now.Format("02/01/2006"))          // 15/01/2024
fmt.Println(now.Format(time.RFC3339))          // 2024-01-15T14:30:45Z

Advanced golang time formatting patterns

Beyond the basic formats, there are some patterns I use regularly in production systems:

now := time.Now()

// API timestamps - RFC3339 is the standard
apiTime := now.Format(time.RFC3339)     // 2024-01-15T14:30:45Z

// Log formatting - readable but sortable  
logTime := now.Format("2006-01-02 15:04:05")  // 2024-01-15 14:30:45

// File naming - no special characters
fileTime := now.Format("20060102-150405")      // 20240115-143045

// User display - depends on audience
userTime := now.Format("02 Jan 2006 at 15:04") // 15 Jan 2024 at 14:30

The key is consistency - pick a format for each use case and stick with it across your entire system.

Parsing datetime strings reliably

Parsing is where things get dangerous. The format you use for parsing must match the input exactly:

// This works
input := "2024-01-15 14:30:45"
parsed, err := time.Parse("2006-01-02 15:04:05", input)
if err != nil {
    log.Fatal(err)
}

// This fails - format doesn't match
input2 := "15/01/2024"
parsed2, err := time.Parse("2006-01-02 15:04:05", input2)
// err: parsing time "15/01/2024": month out of range

Here's the pattern I use for handling multiple possible formats:

func parseFlexibleTime(input string) (time.Time, error) {
    formats := []string{
        time.RFC3339,
        "2006-01-02 15:04:05",
        "2006-01-02T15:04:05",
        "02/01/2006 15:04:05",
        "2006-01-02",
    }
    
    for _, format := range formats {
        if t, err := time.Parse(format, input); err == nil {
            return t, nil
        }
    }
    
    return time.Time{}, fmt.Errorf("unable to parse time: %s", input)
}

Timezone handling that won't bite you

Timezones are where most production bugs happen. The key insight is that time.Time carries timezone information, but comparison and arithmetic operations work correctly regardless.

// Same instant, different timezones
utc := time.Date(2024, 1, 15, 14, 30, 0, 0, time.UTC)
london, _ := time.LoadLocation("Europe/London")
local := utc.In(london)

fmt.Println(utc)   // 2024-01-15 14:30:00 +0000 UTC
fmt.Println(local) // 2024-01-15 14:30:00 +0000 GMT

// These are equal - same instant
fmt.Println(utc.Equal(local)) // true

When parsing times from external sources, always be explicit about timezone:

// Dangerous - assumes local timezone
parsed, _ := time.Parse("2006-01-02 15:04:05", "2024-01-15 14:30:45")

// Better - explicit timezone
parsed, _ := time.ParseInLocation("2006-01-02 15:04:05", "2024-01-15 14:30:45", time.UTC)

Building Production Schedulers

I've built more schedulers than I care to count - data cleanup, token resets, health checks, billing calculations. Here's what actually works in production.

Multi-tier data retention scheduler

Let's build a practical example - a data cleanup system that handles different retention periods based on subscription tiers. Free users get 3 days of data, Basic gets 7 days, Pro gets 30 days.

type RetentionScheduler struct {
    db     *sql.DB
    ticker *time.Ticker
    done   chan bool
}

type RetentionPolicy struct {
    Tier string
    Days int
}

func NewRetentionScheduler(db *sql.DB) *RetentionScheduler {
    return &RetentionScheduler{
        db:   db,
        done: make(chan bool),
    }
}

func (rs *RetentionScheduler) Start() {
    // Run every hour
    rs.ticker = time.NewTicker(time.Hour)
    
    go func() {
        for {
            select {
            case <-rs.ticker.C:
                rs.cleanupExpiredData()
            case <-rs.done:
                return
            }
        }
    }()
}

func (rs *RetentionScheduler) Stop() {
    rs.ticker.Stop()
    rs.done <- true
}

The cleanup logic needs to handle different retention periods efficiently:

func (rs *RetentionScheduler) cleanupExpiredData() {
    policies := []RetentionPolicy{
        {"free", 3},
        {"basic", 7},
        {"pro", 30},
    }
    
    for _, policy := range policies {
        cutoff := time.Now().AddDate(0, 0, -policy.Days)
        
        result, err := rs.db.Exec(`
            DELETE FROM user_data 
            WHERE subscription_tier = $1 
            AND created_at < $2`,
            policy.Tier, cutoff)
            
        if err != nil {
            log.Printf("Error cleaning up %s tier data: %v", policy.Tier, err)
            continue
        }
        
        rowsAffected, _ := result.RowsAffected()
        log.Printf("Cleaned up %d rows for %s tier (older than %s)", 
            rowsAffected, policy.Tier, cutoff.Format("2006-01-02"))
    }
}

Handling scheduler synchronisation

The tricky bit with schedulers is making sure they run at the right time across multiple servers. You don't want every instance running cleanup at slightly different times, creating database load spikes.

Here's the pattern I use to synchronise scheduler start times:

func (rs *RetentionScheduler) StartAtNextHour() {
    now := time.Now()
    nextHour := now.Truncate(time.Hour).Add(time.Hour)
    
    // Wait until the next hour boundary
    time.Sleep(time.Until(nextHour))
    
    // Now start the regular ticker
    rs.Start()
    
    log.Printf("Retention scheduler started at %s", nextHour.Format("15:04:05"))
}

This ensures all your servers start their schedulers at exactly the same time (the top of the hour). For more precise synchronisation, you might want to add a small random delay to avoid thundering herd problems:

func (rs *RetentionScheduler) StartWithJitter() {
    // Add 0-60 seconds of jitter
    jitter := time.Duration(rand.Intn(60)) * time.Second
    time.Sleep(jitter)
    
    rs.StartAtNextHour()
}

Unix timestamps vs time objects

One decision you'll face is whether to store Unix timestamps or use Go's time objects throughout your system. I've tried both approaches extensively.

Unix timestamps are great for:

  • Database storage (integers are fast)
  • JSON serialisation
  • Cross-system compatibility
type Event struct {
    ID        int64 `json:"id"`
    CreatedAt int64 `json:"created_at"` // Unix timestamp
}

// Converting between Unix and time.Time
event := Event{
    ID:        123,
    CreatedAt: time.Now().Unix(),
}

// Convert back to time.Time when you need it
createdTime := time.Unix(event.CreatedAt, 0)

But time.Time objects are better for:

  • Timezone handling
  • Duration calculations
  • Date arithmetic

My current approach is to use Unix timestamps for storage and JSON, but convert to time.Time objects as soon as I need to do any calculations:

func (e *Event) CreatedTime() time.Time {
    return time.Unix(e.CreatedAt, 0)
}

func (e *Event) IsOlderThan(duration time.Duration) bool {
    return time.Since(e.CreatedTime()) > duration
}

Database Timeouts and Performance Monitoring

Database operations are where time handling becomes critical for reliability. You need timeouts that actually work, and monitoring that shows you what's happening.

Context-based query timeouts

The key to reliable database timeouts is using context properly. Here's the pattern I use:

type DatabaseClient struct {
    db *sql.DB
}

func (dc *DatabaseClient) QueryWithTimeout(query string, timeout time.Duration, args ...interface{}) (*sql.Rows, error) {
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()
    
    return dc.db.QueryContext(ctx, query, args...)
}

func (dc *DatabaseClient) GetUserData(userID int64) ([]UserData, error) {
    rows, err := dc.QueryWithTimeout(
        "SELECT id, data, created_at FROM user_data WHERE user_id = $1",
        5*time.Second, // 5 second timeout
        userID,
    )
    if err != nil {
        return nil, fmt.Errorf("query timeout or error: %w", err)
    }
    defer rows.Close()
    
    var results []UserData
    for rows.Next() {
        var ud UserData
        err := rows.Scan(&ud.ID, &ud.Data, &ud.CreatedAt)
        if err != nil {
            return nil, err
        }
        results = append(results, ud)
    }
    
    return results, nil
}

Building timing into database structs

For monitoring database performance, I embed timing directly into my database client. This gives you metrics without changing your application code:

type TimedDB struct {
    db      *sql.DB
    metrics map[string]time.Duration
    mutex   sync.RWMutex
}

func NewTimedDB(db *sql.DB) *TimedDB {
    return &TimedDB{
        db:      db,
        metrics: make(map[string]time.Duration),
    }
}

func (tdb *TimedDB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
    start := time.Now()
    defer func() {
        duration := time.Since(start)
        
        // Simple query identification - first few words
        queryType := strings.Fields(query)[0]
        
        tdb.mutex.Lock()
        tdb.metrics[queryType] = duration
        tdb.mutex.Unlock()
        
        if duration > 100*time.Millisecond {
            log.Printf("Slow query (%s): %s took %v", queryType, query, duration)
        }
    }()
    
    return tdb.db.QueryContext(ctx, query, args...)
}

func (tdb *TimedDB) GetMetrics() map[string]time.Duration {
    tdb.mutex.RLock()
    defer tdb.mutex.RUnlock()
    
    // Return a copy to avoid race conditions
    result := make(map[string]time.Duration)
    for k, v := range tdb.metrics {
        result[k] = v
    }
    return result
}

This approach automatically logs slow queries and gives you performance metrics without changing your existing code.

Connection timeout patterns

Database connections can hang in various ways. Here's a pattern that handles both connection timeouts and query timeouts:

func NewDatabaseWithTimeouts(dsn string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, err
    }
    
    // Connection pool settings with timeouts
    db.SetMaxOpenConns(25)
    db.SetMaxIdleConns(5)
    db.SetConnMaxLifetime(5 * time.Minute)
    
    // Test the connection with a timeout
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    
    if err := db.PingContext(ctx); err != nil {
        return nil, fmt.Errorf("database connection failed: %w", err)
    }
    
    return db, nil
}

Rate Limiting with Time Windows

Rate limiting is one of those features that seems simple until you implement it. I've built token bucket rate limiters, sliding window rate limiters, and fixed window rate limiters. Here's what actually works.

Token bucket rate limiter

The token bucket approach is elegant - you have a bucket that fills with tokens at a steady rate, and each request consumes a token. When the bucket is empty, requests are denied.

type TokenBucket struct {
    capacity     int
    tokens       int
    refillRate   time.Duration
    lastRefill   time.Time
    mutex        sync.Mutex
}

func NewTokenBucket(capacity int, refillRate time.Duration) *TokenBucket {
    return &TokenBucket{
        capacity:     capacity,
        tokens:       capacity,
        refillRate:   refillRate,
        lastRefill:   time.Now(),
    }
}

func (tb *TokenBucket) Allow() bool {
    tb.mutex.Lock()
    defer tb.mutex.Unlock()
    
    // Refill tokens based on time elapsed
    now := time.Now()
    elapsed := now.Sub(tb.lastRefill)
    
    if elapsed >= tb.refillRate {
        tokensToAdd := int(elapsed / tb.refillRate)
        tb.tokens = min(tb.capacity, tb.tokens+tokensToAdd)
        tb.lastRefill = now
    }
    
    // Check if we can allow this request
    if tb.tokens > 0 {
        tb.tokens--
        return true
    }
    
    return false
}

func min(a, b int) int {
    if a < b {
        return a
    }
    return b
}

Per-user rate limiting with time windows

For API rate limiting, you typically want per-user limits. Here's a pattern using a map to track multiple users:

type UserRateLimiter struct {
    buckets map[string]*TokenBucket
    mutex   sync.RWMutex
    
    // Cleanup old buckets periodically
    lastCleanup time.Time
}

func NewUserRateLimiter() *UserRateLimiter {
    return &UserRateLimiter{
        buckets:     make(map[string]*TokenBucket),
        lastCleanup: time.Now(),
    }
}

func (url *UserRateLimiter) Allow(userID string) bool {
    url.mutex.Lock()
    defer url.mutex.Unlock()
    
    // Get or create bucket for this user
    bucket, exists := url.buckets[userID]
    if !exists {
        bucket = NewTokenBucket(100, time.Minute) // 100 requests per minute
        url.buckets[userID] = bucket
    }
    
    // Cleanup old buckets every hour
    if time.Since(url.lastCleanup) > time.Hour {
        url.cleanup()
        url.lastCleanup = time.Now()
    }
    
    return bucket.Allow()
}

func (url *UserRateLimiter) cleanup() {
    // Remove buckets that haven't been used recently
    cutoff := time.Now().Add(-time.Hour)
    for userID, bucket := range url.buckets {
        if bucket.lastRefill.Before(cutoff) {
            delete(url.buckets, userID)
        }
    }
}

Using time.Now() effectively

time.Now() is your starting point for most time operations. The key thing to understand is that it always returns the current time in the system's timezone:

now := time.Now()
fmt.Println(now) // 2024-01-15 14:30:45.123456789 +0000 UTC

// For consistent timestamps across systems, convert to UTC
utcNow := time.Now().UTC()
fmt.Println(utcNow) // 2024-01-15 14:30:45.123456789 +0000 UTC

When storing timestamps in databases or APIs, I always convert to UTC first to avoid timezone confusion across different environments.

Working with Golang Time Sleep and Delays

Sometimes you need to pause execution, whether for rate limiting, retry logic, or coordinating operations. Go provides several ways to handle this, and understanding when to use each approach matters for performance and correctness.

Using time.Sleep for simple delays

The most straightforward approach is time.Sleep, but it blocks the current goroutine completely:

func processWithDelay() {
    for i := 0; i < 5; i++ {
        doWork(i)
        time.Sleep(2 * time.Second) // Blocks for 2 seconds
    }
}

This works fine for simple cases, but it's inflexible. You can't cancel the sleep early or coordinate with other operations.

Using time.After for cancellable delays

time.After returns a channel that delivers the current time after the specified duration. This is more flexible because you can use it in select statements:

func processWithCancellableDelay(ctx context.Context) error {
    for i := 0; i < 5; i++ {
        doWork(i)
        
        select {
        case <-time.After(2 * time.Second):
            // Continue after delay
        case <-ctx.Done():
            return ctx.Err() // Early cancellation
        }
    }
    return nil
}

This pattern is particularly useful in production systems where you need graceful shutdown or cancellation capabilities.

HTTP Request Timing and Performance

Measuring HTTP request performance properly is crucial for understanding your application's behaviour. Here's how I add timing to HTTP handlers without cluttering the business logic.

Request timing middleware

The cleanest approach is middleware that wraps your handlers:

func TimingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        
        // Wrap the ResponseWriter to capture status code
        wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
        
        next.ServeHTTP(wrapped, r)
        
        duration := time.Since(start)
        
        log.Printf("%s %s - %d - %v", 
            r.Method, 
            r.URL.Path, 
            wrapped.statusCode, 
            duration)
        
        // Log slow requests
        if duration > 500*time.Millisecond {
            log.Printf("SLOW REQUEST: %s %s took %v", r.Method, r.URL.Path, duration)
        }
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

Processing time breakdown

For complex handlers, you might want to measure different phases:

func ComplexHandler(w http.ResponseWriter, r *http.Request) {
    timer := NewProcessTimer()
    
    // Database lookup
    timer.Start("database")
    user, err := getUserFromDB(r.Context(), userID)
    timer.End("database")
    if err != nil {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
    
    // External API call
    timer.Start("external_api")
    data, err := fetchExternalData(user.APIKey)
    timer.End("external_api")
    if err != nil {
        http.Error(w, "External API error", http.StatusServiceUnavailable)
        return
    }
    
    // Processing
    timer.Start("processing")
    result := processData(data)
    timer.End("processing")
    
    // Response generation
    timer.Start("response")
    json.NewEncoder(w).Encode(result)
    timer.End("response")
    
    // Log the breakdown
    timer.LogBreakdown(r.URL.Path)
}

type ProcessTimer struct {
    phases map[string]time.Duration
    start  map[string]time.Time
}

func NewProcessTimer() *ProcessTimer {
    return &ProcessTimer{
        phases: make(map[string]time.Duration),
        start:  make(map[string]time.Time),
    }
}

func (pt *ProcessTimer) Start(phase string) {
    pt.start[phase] = time.Now()
}

func (pt *ProcessTimer) End(phase string) {
    if startTime, exists := pt.start[phase]; exists {
        pt.phases[phase] = time.Since(startTime)
        delete(pt.start, phase)
    }
}

func (pt *ProcessTimer) LogBreakdown(endpoint string) {
    total := time.Duration(0)
    for phase, duration := range pt.phases {
        total += duration
        log.Printf("%s - %s: %v", endpoint, phase, duration)
    }
    log.Printf("%s - total: %v", endpoint, total)
}

Common Gotchas and Best Practices

After years of working with Go's time package, here are the mistakes I see repeatedly and how to avoid them.

Timer and ticker cleanup

This is probably the most common source of goroutine leaks. Always stop your timers and tickers:

// Bad - goroutine leak
func badExample() {
    ticker := time.NewTicker(time.Second)
    for range ticker.C {
        doSomething()
        if shouldStop {
            return // ticker never stopped!
        }
    }
}

// Good - proper cleanup
func goodExample() {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()
    
    for range ticker.C {
        doSomething()
        if shouldStop {
            return // defer will stop the ticker
        }
    }
}

Duration parsing edge cases

Parsing durations with time.ParseDuration

Go's duration parsing is powerful but has some quirks. Here's what works and what doesn't:

// These work as expected
d1, _ := time.ParseDuration("1h")     // 1 hour
d2, _ := time.ParseDuration("30m")    // 30 minutes  
d3, _ := time.ParseDuration("45s")    // 45 seconds
d4, _ := time.ParseDuration("1.5h")   // 1 hour 30 minutes
d5, _ := time.ParseDuration("90m")    // 1 hour 30 minutes (same as above)

// This fails - days aren't supported
_, err := time.ParseDuration("1d")    // Error: time: unknown unit "d"

For handling days and other custom units, you'll need to build your own parser:

func parseDurationWithDays(s string) (time.Duration, error) {
    if strings.HasSuffix(s, "d") {
        days, err := strconv.Atoi(s[:len(s)-1])
        if err != nil {
            return 0, err
        }
        return time.Duration(days) * 24 * time.Hour, nil
    }
    return time.ParseDuration(s)
}

Timezone database updates

Your applications can break when timezone databases are updated. This is rare, but it happens:

// Safer timezone handling
func safeLoadLocation(name string) *time.Location {
    loc, err := time.LoadLocation(name)
    if err != nil {
        log.Printf("Failed to load timezone %s: %v, falling back to UTC", name, err)
        return time.UTC
    }
    return loc
}

// Use it like this
userTZ := safeLoadLocation("Europe/London")
localTime := utcTime.In(userTZ)

Time comparison precision

Go's time comparison can be more precise than you expect. For database timestamps, this can cause issues:

// Database timestamps often lose precision
original := time.Now()
stored := original.Truncate(time.Microsecond) // Simulate database storage

fmt.Println(original.Equal(stored)) // false!

// For database comparisons, truncate to appropriate precision
func timeEqual(t1, t2 time.Time) bool {
    return t1.Truncate(time.Microsecond).Equal(t2.Truncate(time.Microsecond))
}

Performance considerations

Time operations are generally fast, but there are some patterns to avoid in hot paths:

// Expensive - creates new time objects repeatedly
func expensive() {
    for i := 0; i < 1000000; i++ {
        if time.Since(time.Now()) > time.Second {
            // This is inefficient
        }
    }
}

// Better - calculate once
func efficient() {
    start := time.Now()
    deadline := start.Add(time.Second)
    
    for i := 0; i < 1000000; i++ {
        if time.Now().After(deadline) {
            break
        }
    }
}

Conclusion

Working with time in Go is straightforward once you understand the fundamentals, but the production patterns matter more than the basics. The key insights that have saved me the most debugging time are:

Always be explicit about timezones when parsing external data. That 2am debugging session I mentioned at the start could have been avoided with proper timezone handling.

Use context timeouts for all database operations. Network calls can hang indefinitely, and context cancellation is your safety net.

Clean up your timers and tickers. This is the easiest way to leak goroutines, and it's completely avoidable with proper defer statements.

Build timing into your systems from the start. Adding performance monitoring after you have problems is much harder than building it in from day one.

The patterns I've shown here - retention schedulers, database timing, rate limiters, request monitoring - are the ones I use in production systems that handle real load. They're not the simplest implementations, but they're the ones that work reliably when things go wrong.

Time handling might seem like a boring topic, but it's one of those foundational skills that makes the difference between systems that work in development and systems that work in production. Get it right early, and you'll avoid a lot of late-night debugging sessions.

If you're building systems that handle scheduling, rate limiting, or time-based logic, I'd recommend starting with these patterns and adapting them to your specific needs. The extra complexity upfront pays for itself the first time your schedulers run correctly across multiple servers, or when your rate limiter handles a traffic spike without falling over.

For more production patterns and practical Go development tips, you might find my articles on 80x caching improvement in Go and building a rules engine for trading useful. I've also written about automating Git commits using AI if you're interested in developer productivity tools. The same attention to production reliability applies across all these systems.


Need help with your business?

Enjoyed this post? I help companies navigate AI implementation, fintech architecture, and technical strategy. Whether you're scaling engineering teams or building AI-powered products, I'd love to discuss your challenges.

Learn more about how I can support you.

Get practical insights weekly

Real solutions to real problems. No fluff, just production-ready code and workflows that work.
You've successfully subscribed to Kyle Redelinghuys
Great! Next, complete checkout to get full access to all premium content.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Error! Stripe checkout failed.
Success! Your billing info is updated.
Error! Billing info update failed.