Usage-based SaaS pricing requires a mechanism to count what each customer consumes and report those counts to the billing provider. The naive approach—calling the Stripe API on every request—adds latency to the hot path and creates a tight coupling between request processing and billing infrastructure. A background goroutine with ticker-based accumulation solves this by decoupling usage recording from usage reporting: the request path increments an in-memory counter, and a background goroutine periodically flushes accumulated counts to Stripe’s metered billing API.
This pattern is a specific instance of the broader “accumulate and flush” architecture used throughout systems programming—from write-ahead logs to metrics collectors. The key design decisions are the flush interval (trading reporting latency for API call efficiency), the accumulation granularity (per-customer, per-meter, per-time-bucket), and the failure handling strategy (what happens when Stripe is unreachable during a flush).
As a concrete example, consider a multi-tenant search application implementing this pattern for per-request usage-based pricing. Every search API call increments an in-memory counter keyed by (customer_id, “search_requests”). A background goroutine wakes every 60 seconds, reads the accumulated counts, resets them, and reports each customer’s total to Stripe. If the Stripe call fails, the counts are re-added to the accumulator for the next flush cycle.
Go’s time.Ticker provides a channel that fires at regular intervals. The background reporter creates a ticker (e.g., every 60 seconds), selects on both the ticker channel and a context cancellation channel, and flushes accumulated usage on each tick. On context cancellation (graceful shutdown), it performs a final flush to avoid losing in-flight counts. This pattern is lightweight—a single goroutine handles all customers—and naturally rate-limits Stripe API calls.
Usage counts must be safe for concurrent increment from multiple request-handling goroutines. The accumulator uses sync.Mutex to protect a map of counters, or alternatively sync/atomic for single-counter hot paths. During flush, the current counts are swapped atomically with a fresh map, so request handlers never block waiting for the Stripe API call to complete. This swap-and-flush pattern ensures zero contention between the hot path and the background reporter.
When a Stripe API call fails (network timeout, rate limit, 5xx error), the unflushed counts cannot be discarded—that would mean under-billing. Instead, the failed counts are added back to the accumulator so the next tick retries them along with any new counts that arrived in the meantime. This naturally coalesces retries: if Stripe is down for 5 minutes, only one API call per customer is needed when it recovers, containing the full accumulated count.
When the application receives a shutdown signal (SIGTERM), it must flush any accumulated but unreported usage before exiting. The background goroutine’s select loop includes the context’s Done channel; when it fires, a final flush is performed with a bounded timeout. If the final flush fails, the counts should be persisted to durable storage (a local file or database) so they can be recovered and reported on the next startup.
This pattern reports aggregate counts (e.g., “customer X made 1,247 search requests in the last 60 seconds”) rather than individual events. This is appropriate when the billing model charges per-unit (per search, per API call) and doesn’t need per-event metadata. For billing models that need event-level detail (e.g., different prices per query complexity), the bucketed UPSERT pattern from the Stripe Billing Meters API is more appropriate.
The implementation consists of three components: a thread-safe accumulator, a background reporter goroutine, and a middleware that instruments the request path.
package billing
import (
"sync"
)
type CounterKey struct {
CustomerID string
Meter string
}
type UsageAccumulator struct {
mu sync.Mutex
counters map[CounterKey]int64
}
func NewUsageAccumulator() *UsageAccumulator {
return &UsageAccumulator{
counters: make(map[CounterKey]int64),
}
}
func (a *UsageAccumulator) Increment(customerID, meter string, n int64) {
key := CounterKey{CustomerID: customerID, Meter: meter}
a.mu.Lock()
a.counters[key] += n
a.mu.Unlock()
}
func (a *UsageAccumulator) SwapAndDrain() map[CounterKey]int64 {
a.mu.Lock()
old := a.counters
a.counters = make(map[CounterKey]int64, len(old))
a.mu.Unlock()
return old
}
func (a *UsageAccumulator) Restore(counts map[CounterKey]int64) {
a.mu.Lock()
for key, val := range counts {
a.counters[key] += val
}
a.mu.Unlock()
}
package billing
import (
"context"
"fmt"
"log"
"time"
"github.com/stripe/stripe-go/v76"
"github.com/stripe/stripe-go/v76/billing/meterevent"
)
type BackgroundReporter struct {
accumulator *UsageAccumulator
interval time.Duration
}
func NewBackgroundReporter(acc *UsageAccumulator, interval time.Duration) *BackgroundReporter {
return &BackgroundReporter{
accumulator: acc,
interval: interval,
}
}
func (br *BackgroundReporter) Run(ctx context.Context) {
ticker := time.NewTicker(br.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
br.flush(context.Background(), 5*time.Second)
return
case <-ticker.C:
br.flush(ctx, 30*time.Second)
}
}
}
func (br *BackgroundReporter) flush(ctx context.Context, timeout time.Duration) {
counts := br.accumulator.SwapAndDrain()
if len(counts) == 0 {
return
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var failed map[CounterKey]int64
for key, value := range counts {
if err := br.reportToStripe(ctx, key, value); err != nil {
log.Printf("stripe report failed for %s/%s: %v",
key.CustomerID, key.Meter, err)
if failed == nil {
failed = make(map[CounterKey]int64)
}
failed[key] = value
}
}
if len(failed) > 0 {
br.accumulator.Restore(failed)
log.Printf("re-queued %d failed usage reports for next cycle", len(failed))
}
}
func (br *BackgroundReporter) reportToStripe(ctx context.Context, key CounterKey, value int64) error {
params := &stripe.BillingMeterEventParams{
EventName: stripe.String(key.Meter),
Payload: map[string]string{
"stripe_customer_id": key.CustomerID,
"value": fmt.Sprintf("%d", value),
},
Timestamp: stripe.Int64(time.Now().Unix()),
}
params.SetContext(ctx)
_, err := meterevent.New(params)
return err
}
package billing
import (
"net/http"
)
type UsageMiddleware struct {
accumulator *UsageAccumulator
meterName string
}
func NewUsageMiddleware(acc *UsageAccumulator, meter string) *UsageMiddleware {
return &UsageMiddleware{
accumulator: acc,
meterName: meter,
}
}
func (m *UsageMiddleware) Wrap(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
customerID := getCustomerIDFromContext(r.Context())
if customerID != "" {
m.accumulator.Increment(customerID, m.meterName, 1)
}
})
}
package main
import (
"context"
"net/http"
"os/signal"
"syscall"
"time"
"myapp/billing"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
acc := billing.NewUsageAccumulator()
reporter := billing.NewBackgroundReporter(acc, 60*time.Second)
middleware := billing.NewUsageMiddleware(acc, "search_requests")
go reporter.Run(ctx)
mux := http.NewServeMux()
mux.Handle("/api/search", middleware.Wrap(searchHandler))
srv := &http.Server{Addr: ":8080", Handler: mux}
go srv.ListenAndServe()
<-ctx.Done()
srv.Shutdown(context.Background())
}
In a typical usage-based SaaS platform, this pattern is the core billing integration point. Every search API request passes through the usage middleware, which increments the per-customer counter. The background reporter runs with a 60-second interval, meaning Stripe receives at most one API call per customer per minute regardless of request volume. For a customer making 10,000 searches per minute, this reduces Stripe API calls from 10,000 to 1—a 10,000x reduction.
The pattern extends naturally to multiple meters. For instance, a system might track three usage dimensions: search_requests (per query), documents_indexed (per ingestion), and embedding_tokens (per embedding computation). Each has its own counter key in the accumulator, and the background reporter flushes all three in each cycle. Adding a new billing dimension requires only adding a new Increment call in the relevant code path.
For high-availability deployments with multiple application instances, each instance runs its own background reporter. Stripe aggregates usage events from all instances automatically since the events use the same meter name and customer ID. There is no need for cross-instance coordination—each instance independently accumulates and reports its share of the traffic.
package billing
import (
"encoding/json"
"os"
"path/filepath"
)
type OverflowBuffer struct {
path string
}
func NewOverflowBuffer(dir string) *OverflowBuffer {
return &OverflowBuffer{path: filepath.Join(dir, "usage_overflow.json")}
}
func (ob *OverflowBuffer) Save(counts map[CounterKey]int64) error {
data, err := json.Marshal(counts)
if err != nil {
return err
}
return os.WriteFile(ob.path, data, 0600)
}
func (ob *OverflowBuffer) Load() (map[CounterKey]int64, error) {
data, err := os.ReadFile(ob.path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var counts map[CounterKey]int64
if err := json.Unmarshal(data, &counts); err != nil {
return nil, err
}
os.Remove(ob.path)
return counts, nil
}
package billing
import (
"github.com/prometheus/client_golang/prometheus"
)
var (
usageRecorded = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "billing_usage_recorded_total",
Help: "Total usage events recorded by the accumulator",
},
[]string{"meter"},
)
usageFlushed = prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "billing_usage_flushed_total",
Help: "Total usage events successfully flushed to Stripe",
},
[]string{"meter"},
)
flushErrors = prometheus.NewCounter(prometheus.CounterOpts{
Name: "billing_flush_errors_total",
Help: "Total failed flush attempts to Stripe",
})
flushDuration = prometheus.NewHistogram(prometheus.HistogramOpts{
Name: "billing_flush_duration_seconds",
Help: "Duration of flush cycles",
Buckets: prometheus.DefBuckets,
})
)