AWS Lambda Provisioned Concurrency: Eliminating Cold Starts

Posted: | Last updated: | 5 minute read

Lambda cold starts — that 500ms to 10-second delay when a new execution environment initializes — are the biggest complaint about serverless. For most workloads, cold starts are a rounding error. But for API endpoints with strict latency SLAs, payment processing functions, or interactive applications, a multi-second delay on the first request is unacceptable. Provisioned concurrency keeps a pool of pre-initialized execution environments warm and ready, eliminating cold starts entirely.

What Causes Cold Starts?

A cold start happens when Lambda creates a new execution environment. The full cycle:

  1. Download code — Lambda fetches your deployment package from S3
  2. Start runtime — Initialize the language runtime (JVM, .NET CLR, Node.js V8)
  3. Run init code — Execute code outside the handler (imports, DB connections, SDK clients)
  4. Execute handler — Process the actual request
Runtime Typical Cold Start With VPC With Provisioned Concurrency
Python 3.9 200-500ms +1-2s ~0ms
Node.js 18 150-400ms +1-2s ~0ms
Java 17 2-8s +2-4s ~0ms
.NET 6 400-800ms +1-2s ~0ms

Java and .NET suffer the worst because their runtimes require JIT compilation. Provisioned concurrency pre-runs all initialization steps, so when a request arrives, the handler executes immediately on an already-warm environment.

Configuring Provisioned Concurrency

Enable provisioned concurrency on a published version or alias (not $LATEST):

# Publish a new version
aws lambda publish-version \
  --function-name payment-processor \
  --description "v2.1.0 - optimized checkout"

# Set provisioned concurrency on that version
aws lambda put-provisioned-concurrency-config \
  --function-name payment-processor \
  --qualifier 3 \
  --provisioned-concurrent-executions 50

# Check provisioned concurrency status
aws lambda get-provisioned-concurrency-config \
  --function-name payment-processor \
  --qualifier 3

Or configure via an alias (recommended for deployments):

# Create/update alias pointing to the version
aws lambda update-alias \
  --function-name payment-processor \
  --name production \
  --function-version 3

# Set provisioned concurrency on the alias
aws lambda put-provisioned-concurrency-config \
  --function-name payment-processor \
  --qualifier production \
  --provisioned-concurrent-executions 50

Terraform configuration:

resource "aws_lambda_function" "payment_processor" {
  function_name = "payment-processor"
  runtime       = "java17"
  handler       = "com.example.PaymentHandler::handleRequest"
  filename      = "payment-processor.jar"
  memory_size   = 1024
  timeout       = 30

  environment {
    variables = {
      DB_ENDPOINT = var.db_endpoint
      STAGE       = "production"
    }
  }
}

resource "aws_lambda_alias" "production" {
  name             = "production"
  function_name    = aws_lambda_function.payment_processor.function_name
  function_version = aws_lambda_function.payment_processor.version
}

resource "aws_lambda_provisioned_concurrency_config" "production" {
  function_name                  = aws_lambda_function.payment_processor.function_name
  provisioned_concurrent_executions = 50
  qualifier                      = aws_lambda_alias.production.name
}

Auto-Scaling Provisioned Concurrency

Static provisioned concurrency wastes money during low-traffic periods. Use Application Auto Scaling to adjust based on utilization:

resource "aws_appautoscaling_target" "lambda_target" {
  max_capacity       = 200
  min_capacity       = 10
  resource_id        = "function:${aws_lambda_function.payment_processor.function_name}:${aws_lambda_alias.production.name}"
  scalable_dimension = "lambda:function:ProvisionedConcurrency"
  service_namespace  = "lambda"
}

# Scale based on utilization — target 70%
resource "aws_appautoscaling_policy" "lambda_policy" {
  name               = "lambda-provisioned-concurrency"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.lambda_target.resource_id
  scalable_dimension = aws_appautoscaling_target.lambda_target.scalable_dimension
  service_namespace  = aws_appautoscaling_target.lambda_target.service_namespace

  target_tracking_scaling_policy_configuration {
    target_value = 0.7  # Scale up when 70% of provisioned capacity is used

    predefined_metric_specification {
      predefined_metric_type = "LambdaProvisionedConcurrencyUtilization"
    }

    scale_in_cooldown  = 60
    scale_out_cooldown = 0
  }
}

# Scheduled scaling for known traffic patterns
resource "aws_appautoscaling_scheduled_action" "morning_rush" {
  name               = "morning-scale-up"
  service_namespace  = aws_appautoscaling_target.lambda_target.service_namespace
  resource_id        = aws_appautoscaling_target.lambda_target.resource_id
  scalable_dimension = aws_appautoscaling_target.lambda_target.scalable_dimension
  schedule           = "cron(0 8 ? * MON-FRI *)"

  scalable_target_action {
    min_capacity = 100
    max_capacity = 200
  }
}

resource "aws_appautoscaling_scheduled_action" "evening_scale_down" {
  name               = "evening-scale-down"
  service_namespace  = aws_appautoscaling_target.lambda_target.service_namespace
  resource_id        = aws_appautoscaling_target.lambda_target.resource_id
  scalable_dimension = aws_appautoscaling_target.lambda_target.scalable_dimension
  schedule           = "cron(0 20 ? * MON-FRI *)"

  scalable_target_action {
    min_capacity = 10
    max_capacity = 50
  }
}

This configuration keeps 10 environments warm during off-hours and scales to 100-200 during business hours. Target tracking at 70% means auto-scaling adds capacity before you run out of warm environments.

Cost Analysis: When It Makes Sense

Provisioned concurrency has a separate pricing model:

Provisioned:  $0.0000041667 per GB-second (~$0.015/GB-hour)
On-demand:    $0.0000166667 per GB-second (~$0.06/GB-hour)

Provisioned is ~4x cheaper per execution hour BUT you pay 24/7

Break-even calculation:

# 1024MB function, provisioned concurrency of 50
provisioned_cost_per_hour = 50 * 1.0 * 0.0000041667 * 3600  # $0.75/hour
provisioned_cost_per_month = provisioned_cost_per_hour * 24 * 30  # $540/month

# On-demand equivalent — how many invocations to break even?
# If average duration is 200ms:
on_demand_per_invocation = 1.0 * 0.2 * 0.0000166667  # $0.0000033
break_even_invocations = provisioned_cost_per_month / on_demand_per_invocation
# ~163 million invocations/month to break even on cost alone

Provisioned concurrency is not a cost optimization — it’s a latency optimization. Use it when:

  • P99 latency SLAs prohibit cold starts
  • Java/.NET functions with 3-10 second cold starts power interactive APIs
  • VPC-attached functions need sub-second response times
  • Downstream services (databases, APIs) can’t handle burst connections from simultaneous cold starts

Best Practices

  1. Use aliases, not version numbers — point provisioned concurrency at an alias like production. When you deploy a new version, update the alias. Provisioned concurrency transfers to the new version automatically during the alias update.
  2. Optimize your init code first — before paying for provisioned concurrency, reduce cold start times. Lazy-load SDK clients, minimize dependencies, use lighter frameworks. A 200ms cold start might not need provisioned concurrency at all.
  3. Monitor ProvisionedConcurrencySpilloverInvocations — this CloudWatch metric shows requests that exceeded your provisioned capacity and fell back to on-demand (with cold starts). If spillover is high, increase your provisioned concurrency or auto-scaling limits.
  4. Combine with SnapStart for Java — Lambda SnapStart (Java 11+) snapshots the initialized JVM state. Combined with provisioned concurrency, Java cold starts drop from seconds to sub-100ms even for on-demand overflow invocations.
  5. Don’t provision $LATEST — provisioned concurrency requires a published version or alias. Using aliases with weighted routing lets you canary-deploy new versions while maintaining warm environments for the stable version.

Conclusion

Provisioned concurrency eliminates Lambda cold starts by maintaining pre-initialized execution environments. Auto-scaling adjusts capacity based on utilization or schedules, preventing waste during low-traffic periods. The cost is significant — you pay for provisioned GB-seconds 24/7 — so reserve it for latency-sensitive workloads where cold starts violate SLAs. For Java and .NET functions behind synchronous APIs, provisioned concurrency turns Lambda into a warm, responsive compute platform.

Key Takeaways:

  • Provisioned concurrency pre-initializes execution environments, eliminating cold starts
  • Configure on aliases (not $LATEST) for seamless deployments
  • Auto-scaling with target tracking and scheduled actions optimizes cost
  • Monitor ProvisionedConcurrencySpilloverInvocations to detect capacity shortfalls

Frequently Asked Questions

Q: Does provisioned concurrency work with Lambda@Edge? No. Lambda@Edge doesn’t support provisioned concurrency. CloudFront functions and Lambda@Edge rely on AWS’s global replication for low-latency execution. For latency-sensitive edge processing, use CloudFront Functions (JavaScript only, sub-millisecond starts) or keep Lambda@Edge functions lightweight with minimal dependencies.

Q: How long does it take to provision environments? Typically 1-5 minutes depending on function size, memory, and runtime. Java functions with large classpaths take longer. The get-provisioned-concurrency-config API shows the status — it transitions from IN_PROGRESS to READY. Plan deployments accordingly and don’t delete the old version until the new one is ready.

Q: Can I use provisioned concurrency with Lambda container images? Yes. Container image-based Lambda functions support provisioned concurrency the same way as ZIP-based functions. Container images can have higher cold start times (due to larger image sizes), making provisioned concurrency especially valuable for containerized Lambda functions.