← All blogs
aws lambda · go · serverless · backend architecture

Cold Start Arithmetic: Why Your Lambda-Backed Go Service Is Slower Than You Measured

Cold start mechanics, SnapStart gaps, and provisioned concurrency math for Go Lambda services under real production traffic patterns.

Cold Start Arithmetic: Why Your Lambda-Backed Go Service Is Slower Than You Measured

The benchmark you ran says p99 latency is 12 ms. Your CloudWatch dashboard shows a different story during traffic spikes: 800 ms tail latencies appearing in bursts, followed by recovery. The discrepancy is not a fluke. It is a cold start tax compounding across concurrent invocations, and the arithmetic is almost never done correctly before a team commits Lambda to a latency-sensitive path.

This article works through the mechanics of Go Lambda cold starts, the failure modes that synthetic benchmarks miss, and the cost-versus-latency decision surface for provisioned concurrency.

What a Cold Start Actually Costs in Go

When Lambda has no warm execution environment available, it must:

  1. Download and extract your deployment package or container image
  2. Initialize the MicroVM via Firecracker
  3. Start the Lambda runtime shim
  4. Execute your init() functions and package-level variable initializations
  5. Invoke your handler

Steps 1–3 are platform overhead you cannot control. For a zip-packaged Go binary under 20 MB, this overhead is typically 150–300 ms. For a container image, it can exceed 1 s on first pull, even with ECR image caching optimizations. Step 4 is where Go engineers routinely bleed time without realizing it.

Consider a typical initialization block:

var (
    db     *mongo.Client
    cache  *redis.Client
    cfg    *config.AppConfig
)

func init() {
    cfg = config.MustLoad() // reads SSM Parameter Store: ~80 ms
    db = mongo.MustConnect(cfg.MongoURI) // TCP + TLS + handshake: ~120 ms
    cache = redis.MustConnect(cfg.RedisAddr) // TCP + AUTH: ~30 ms
}

Those three sequential network calls add 230 ms to every cold start. In a service that was benchmarked in a warm state, this cost is invisible. In production, when a traffic spike forces Lambda to initialize 50 concurrent environments simultaneously, you are paying 230 ms per new environment, and each of those environments is blocked on network I/O that could be parallelized.

Rewrite the initialization with sync.WaitGroup or errgroup:

func initDependencies(ctx context.Context) error {
    g, ctx := errgroup.WithContext(ctx)

    g.Go(func() error {
        var err error
        cfg, err = config.Load(ctx)
        return err
    })

    g.Go(func() error {
        var err error
        db, err = mongo.Connect(ctx, cfg.MongoURI) // cfg race: see note
        return err
    })

    g.Go(func() error {
        var err error
        cache, err = redis.Connect(ctx, cfg.RedisAddr)
        return err
    })

    return g.Wait()
}

Note the data race on cfg: you cannot read cfg in the MongoDB goroutine until the config goroutine has written it. The practical fix is to load config synchronously first (it is a single SSM batch call), then parallelize the two connection dials. This reduces the 230 ms initialization to roughly 120 ms — the MongoDB connection latency dominates and the Redis dial overlaps with it.

The Concurrency Cliff

Lambda scales by adding execution environments, not threads within an environment. Each environment handles exactly one concurrent invocation. When your service receives a burst — say 200 requests in 100 ms against a function that normally idles at 5 concurrent environments — Lambda must provision up to 195 new environments.

AWS allows up to 500–3000 initial burst concurrency (region-dependent), then adds 500 environments per minute thereafter. If your burst exceeds the burst limit, requests are throttled with a 429 TooManyRequestsException. If they are within the limit but require cold starts, every request in that burst pays the cold start tax.

The compounding effect: with 200 simultaneous cold starts each taking 400 ms total (platform + init), you have 200 invocations with 400 ms added latency, all at once. API Gateway's default integration timeout is 29 seconds, so they will not time out — but your clients see 400 ms extra on every one of those requests.

Provisioned Concurrency: The Correct Math

Provisioned concurrency (PC) keeps N environments initialized and warm. Requests routed to those environments pay zero cold start penalty. Requests that overflow PC spill into on-demand environments, which cold-start normally.

The decision requires three numbers:

  • Baseline concurrency: the concurrent invocations you serve at steady state (not RPS — concurrent invocations, which equals RPS × avg_duration_seconds)
  • Burst headroom: how much above baseline you expect traffic spikes to reach before the spike stabilizes
  • PC cost: $0.0000646 per GB-second of PC allocation plus $0.0000097 per GB-second of request execution, versus $0.0000166667 per GB-second for on-demand

PC costs roughly 3.9× more per GB-second than on-demand compute for the reserved capacity. Whether that is worth it depends entirely on your SLA and traffic shape.

For a function with 512 MB memory, running at 50 concurrent invocations baseline:

PC cost per hour = 50 envs × 0.5 GB × 3600 s × $0.0000646 = $5.81/hour
On-demand equivalent = 50 × 0.5 × 3600 × $0.0000166667 = $1.50/hour
Premium for warmth = $4.31/hour = ~$3,100/month

For a service with a p99 SLA of 200 ms where cold starts add 400 ms, that premium is likely worth it. For an async processing pipeline where a 400 ms cold start on 0.1% of invocations is invisible, it is not.

Application Auto Scaling for Provisioned Concurrency

Hard-coding PC at your peak concurrency means paying peak prices at 3 AM. Application Auto Scaling with scheduled or target-tracking policies is the correct operational pattern.

A target-tracking policy on the ProvisionedConcurrencyUtilization metric at 70% target keeps PC ahead of demand without massively overprovisioning. The caveat: scaling out PC takes 2–3 minutes. If your traffic spikes faster than that — a flash sale, a push notification blast — scheduled scaling ahead of the event is necessary.

For predictable spikes, configure scheduled actions:

// Pseudocode for AWS SDK v2 call to register scheduled action
scalingClient.PutScheduledAction(ctx, &applicationautoscaling.PutScheduledActionInput{
    ServiceNamespace:  types.ServiceNamespaceLambda,
    ResourceId:        aws.String("function:my-function:prod"),
    ScalableDimension: types.ScalableDimensionLambdaFunctionProvisionedConcurrency,
    ScheduledActionName: aws.String("pre-scale-for-sale"),
    Schedule:          aws.String("cron(0 13 * * ? *)"), // 1 PM UTC daily
    ScalableTargetAction: &types.ScalableTargetAction{
        MinCapacity: aws.Int32(200),
        MaxCapacity: aws.Int32(200),
    },
})

Pair this with a scale-down action 4 hours later. The 2-minute provisioning lead time means you schedule the pre-scale action at least 5 minutes before the event.

Container Images vs. Zip Packages: The ECR Tradeoff

Container images on Lambda offer dependency flexibility and image reuse with ECS/EKS, but they introduce two cold start penalties zip packages do not: image pull time and layer extraction. ECR caches layers within a region, and Lambda caches images per execution environment after first pull — but the first invocation on a new environment in a new availability zone or after an image update can pay 500 ms to 2 s in pull overhead.

For Go services specifically, the multi-stage Docker build produces a scratch-based image that is often smaller than 20 MB:

FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-s -w' -o handler ./cmd/lambda

FROM public.ecr.aws/lambda/provided:al2023
COPY --from=builder /app/handler /var/runtime/bootstrap
CMD ["handler"]

A 15 MB image versus a 15 MB zip package has comparable pull times. The image wins on operational uniformity; the zip package wins on cold start predictability because there is no layer extraction step.

Observability: Measuring What Matters

Lambda emits Init Duration in the REPORT log line for cold starts. Parsing this from CloudWatch Logs Insights gives you actual cold start frequency and duration, broken down by function version and alias:

fields @timestamp, @message
| filter @message like /Init Duration/
| parse @message "Init Duration: * ms" as initDuration
| stats avg(initDuration), p99(initDuration), count() by bin(5m)

Track the ratio of cold-start invocations to total invocations. A ratio above 1% on a latency-sensitive path is a signal to revisit PC configuration. A ratio below 0.1% on an async path means PC is almost certainly not worth the cost.

Emit a structured log field from your handler's init path with the initialization duration so you can correlate init cost with downstream latency at the trace level — X-Ray subsegments do not automatically capture init() time.

Decision Framework

Before committing a Go service to Lambda on a latency-sensitive path:

  1. Measure actual cold start cost: profile your init() path in isolation. Every synchronous network call is a multiplier on cold start latency.
  2. Parallelize initialization I/O: use errgroup for independent dials; sequence only when there are true data dependencies.
  3. Calculate PC break-even: if cold starts affect more than 0.5% of requests and your SLA is under 500 ms p99, PC is likely cost-justified.
  4. Use scheduled scaling for predictable bursts: target-tracking alone cannot react fast enough to sub-5-minute spikes.
  5. Choose zip over container for latency-critical functions unless you have a strong operational reason for image uniformity.
  6. Instrument Init Duration separately from handler duration; aggregate it on a per-alias basis so version deployments do not pollute your steady-state metrics.

Lambda is the right tool for sporadic, event-driven workloads where concurrency is unpredictable and operational overhead matters more than single-digit millisecond tail latency. For services with sustained concurrency above 50 and p99 SLAs under 200 ms, the provisioned concurrency premium and initialization complexity frequently tip the math toward ECS Fargate with an application load balancer — not because Lambda cannot do it, but because the operational cost of getting it right at scale exceeds the infrastructure savings.

Cold Start Arithmetic: Why Your Lambda-Backed Go Service Is Slower Than You Measured | Neeraj Singhi