Blog Post

Microsoft Developer Community Blog
10 MIN READ

Performance Tuning Cold Starts, Scaling Delays, and Startup Latency in Azure Container Apps

BhaktiRath95's avatar
BhaktiRath95
Icon for Microsoft rankMicrosoft
Jun 11, 2026

Troubleshooting Azure Container Apps in Production | Part 2 of 4

Introduction

There is a particular kind of frustration that comes not when your application fails to start, but when it starts too slowly. The container is running, the health probes pass, your monitoring shows green — and yet every few minutes a user somewhere in the world hits a request that takes 15 seconds to respond. Your support team starts getting tickets. Your SLA dashboard turns amber.

This is the cold start problem, and it is one of the most widely discussed pain points with any serverless container platform. Azure Container Apps is no different. But what most engineers do not realize is that the cold start is only one part of the story. Scaling delays, inefficient image layers, wrong resource allocations, and misconfigured KEDA rules all compound to create latency spikes that feel indistinguishable from cold starts but have completely different root causes and fixes.

In this part of the series, we break down each cause systematically and show you exactly how to address it.

Understanding What "Cold Start" Actually Means in Container Apps

Before we fix anything, it helps to understand what is actually happening during a cold start. When a new replica is created, Azure Container Apps needs to do several things in sequence before your application can serve a single request:

 

  1. The platform schedules the new replica on available infrastructure.
  2. The container runtime pulls the image layers that are not already cached on that node.
  3. The container starts and the process inside it begins executing.
  4. Your application framework initializes (the .NET DI container, Django's ORM layer, loaded ML models, etc.).
  5. The readiness probe passes, signaling that the replica can accept traffic.

 

Every one of these steps takes time. The total duration is your cold start latency. When you have `minReplicas: 0`, this full cycle happens for every "first request after idle" scenario. With `minReplicas: 1`, steps 1 and 2 are already done, but steps 3–5 still happen whenever a new replica is created due to scaling out.

Scenario 1: Requests Spike to 10+ Seconds After a Period of Inactivity
What You See

Everything looks fine during load testing, but the next morning after a quiet night, the first user to hit the app gets a timeout or a very slow response. You check your Application Insights or Log Analytics and you see exactly one request with a dramatically higher duration than all the others.

Why This Happens

You have `minReplicas` set to `0` (or it defaults to 0). When there are no replicas running and a new request arrives, the entire cold start sequence kicks off — and the request waits in the ingress queue the entire time. Depending on your image size and application initialization time, this can easily reach 15–30 seconds for a .NET application with a large DI graph, or even longer for a Python application that imports heavy libraries.

The Fix

Option A (Recommended for most workloads): Set `minReplicas` to 1.

This ensures at least one replica is always warm and ready to handle requests. You will pay for that one replica's compute even during idle periods, but you eliminate the cold start for your users:

az containerapp update 
  --name my-dotnet-api 
  --resource-group my-rg 
  --min-replicas 1 
  --max-replicas 10

 

Or in your Container App YAML:

scale:
  minReplicas: 1
  maxReplicas: 10
  rules:
    - name: http-scaling-rule
      http:
        metadata:
          concurrentRequests: "10"

 

Option B: Reduce image size to speed up the pull.

Every megabyte in your container image adds time to cold starts. A production .NET API should not be a 2 GB image. Use multi-stage builds to strip away the SDK, test tools, and development dependencies:

 

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY ["MyApp.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore

# Stage 2: Runtime only - much smaller
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

# Run as non-root for security
USER app
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

 

For Django, the equivalent pattern is:

FROM python:3.11-slim AS base
# Install only production dependencies
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt 
    && find /usr/local -name "*.pyc" -delete 
    && find /usr/local -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
COPY . .
RUN SECRET_KEY=placeholder python manage.py collectstatic --noinput
USER nobody
EXPOSE 8000
CMD ["gunicorn", "myproject.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2"]

 

Option C: Use a startup probe to manage the readiness window.

If your app genuinely needs 20–30 seconds to initialize (loading configuration, warming caches, establishing connection pools), configure a startup probe separately from your liveness probe. This gives the container time to start without the liveness probe killing it prematurely:

probes:
  - type: Startup
    httpGet:
      path: /health
      port: 8080
    initialDelaySeconds: 5
    periodSeconds: 5
    failureThreshold: 12    # 12 * 5s = 60 seconds total window
  - type: Liveness
    httpGet:
      path: /health
      port: 8080
    periodSeconds: 10
    failureThreshold: 3
  - type: Readiness
    httpGet:
      path: /health/ready
      port: 8080
    periodSeconds: 5
    failureThreshold: 3

 

Scenario 2: New Replicas Lag Behind Traffic Spikes
What You See

Your application handles steady traffic just fine. Then a sudden burst arrives — a marketing email goes out, a scheduled batch job triggers API calls, or a downstream system fires webhooks — and for 30–60 seconds your error rate jumps and your latency spikes. After that window, everything recovers. The scaling logs show new replicas were created, but they came online too late.

Why This Happens

KEDA (the scaling engine behind Container Apps) works reactively. By default, HTTP-based scaling triggers new replicas when concurrent requests exceed the configured threshold. But there is an inherent delay between the moment traffic spikes, the moment KEDA detects it, and the moment a new replica is warm and serving traffic. This window is where your users experience the pain.

 

Additionally, if your image pull takes a long time (large image, first pull on a new node), the new replica arrives even later. KEDA cannot compensate for slow image pulls.

The Fix

Step 1 — Tune your KEDA scaling rules to trigger earlier.

Rather than waiting until you are already at capacity, configure scaling to trigger with a lower concurrency threshold. If your app can handle 20 concurrent requests comfortably, set the threshold to 10 so new replicas spin up before you are overwhelmed:

scale:
  minReplicas: 1
  maxReplicas: 20
  rules:
    - name: http-rule
      http:
        metadata:
          concurrentRequests: "10"   # Scale earlier, not at capacity

 

For Azure Service Bus or Event Hubs-triggered scaling (common in job-style workloads), use a queue length threshold that gives you a buffer:

scale:
  minReplicas: 0
  maxReplicas: 30
  rules:
    - name: servicebus-rule
      custom:
        type: azure-servicebus
        metadata:
          queueName: my-processing-queue
          namespace: my-servicebus-namespace
          messageCount: "5"    # Scale when queue depth reaches 5, not 100
        auth:
          - secretRef: servicebus-connection
            triggerParameter: connection

 

Step 2 — Pre-warm your connection pools in .NET.

 

One of the biggest contributors to new replica slowness is the time spent establishing database connections and other external connections. The first request that hits a new replica bears the cost of opening the connection pool. Configure your connection pool to warm up eagerly at startup:

 

// In Program.cs, after building the app
if (app.Environment.IsProduction())
{
    // Warm up the database connection pool before accepting traffic
    using var scope = app.Services.CreateScope();
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await dbContext.Database.ExecuteSqlRawAsync("SELECT 1");
}

await app.RunAsync();

 

Step 3 — Enable HTTP/2 keep-alive and connection reuse.

 

In .NET applications running behind the Container Apps ingress, configure your HTTP client to use connection pooling properly:

builder.Services.AddHttpClient("downstream-api", client =>
{
    client.BaseAddress = new Uri("https://my-downstream-service");
    client.DefaultRequestVersion = new Version(2, 0);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
    PooledConnectionLifetime = TimeSpan.FromMinutes(5),
    PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
    MaxConnectionsPerServer = 20
});

 

Scenario 3: Django Startup Is Slow Due to Import Time
What You See

 

Your Django application takes 8–12 seconds to start even on a warm node. You check the Gunicorn startup logs and see it spending most of that time in Python module imports before it ever processes a request.

Why This Happens

Python's import system is synchronous and single-threaded. When you import `django`, `rest_framework`, `pandas`, `numpy`, or any large library, Python reads and executes every module file in the dependency chain. A Django project with Django REST Framework, Celery, and a few third-party packages can easily spend 5–8 seconds just on imports. Multiply that by the number of Gunicorn workers (each is a separate process that imports everything independently) and startup time balloons.

The Fix

Step 1 — Profile import time to find the worst offenders.

Add this to your Dockerfile's entrypoint or run it manually:

# Run this in a container shell to see which imports take the longest
python -X importtime -c "import django; django.setup()" 2>&1 | sort -k2 -rn | head -20

 

Step 2 — Use lazy imports for heavy dependencies that are not needed at startup.

 

Instead of importing everything at the module level, defer imports to the functions that actually need them:

 

# Instead of this at the top of your file:
import pandas as pd
import numpy as np

# Do this — import only when the function is actually called:
def process_data(data):
    import pandas as pd
    import numpy as np
    df = pd.DataFrame(data)
    return df.describe().to_dict()

 

Step 3 — Reduce Gunicorn worker count for memory-constrained environments.

Having too many workers means too many independent Python processes all importing everything at the same time. For Container Apps with 0.5–1 vCPU, 2 workers is usually the right starting point:

 

CMD ["gunicorn", "myproject.wsgi:application", 
     "--bind", "0.0.0.0:8000", 
     "--workers", "2", 
     "--worker-class", "gthread", 
     "--threads", "4", 
     "--timeout", "120", 
     "--keep-alive", "5", 
     "--log-level", "info"]

 

Step 4 — Consider switching from Gunicorn to Uvicorn for async Django.

 

If you are on Django 4.x with ASGI support, Uvicorn with async workers can handle significantly more concurrent requests per worker than synchronous Gunicorn workers:

CMD ["uvicorn", "myproject.asgi:application", 
     "--host", "0.0.0.0", 
     "--port", "8000", 
     "--workers", "2", 
     "--log-level", "info"]
Scenario 4: Resource Limits Are Causing Throttling and Slow Responses

What You See

Your application starts fine and handles light traffic well, but under moderate to heavy load — even well below your max replicas — individual requests become slow and CPU metrics show your replicas running near 100% utilization. You may also see the .NET GC (garbage collector) running very frequently, or Django showing slow database queries that are actually fast queries being delayed because the process has no CPU to run.

 Why This Happens

 

Container Apps defaults to 0.25 vCPU and 0.5 Gi memory if you do not specify resource limits. For a production .NET API or a Django application serving real traffic, this is almost always too little. When a container hits its CPU limit, the container runtime throttles it — the process continues to run but gets less CPU time, making everything slower without any obvious error signal.

 The Fix

Step 1 — Measure actual resource usage before guessing.

Query Log Analytics for actual CPU and memory usage to establish a baseline:

ContainerAppSystemLogs_CL
| where ContainerAppName_s == "my-dotnet-api"
| where TimeGenerated > ago(7d)
| summarize
    AvgCpuUsage = avg(todouble(CpuUsageNanoCores_d)) / 1000000,
    MaxCpuUsage = max(todouble(CpuUsageNanoCores_d)) / 1000000,
    AvgMemoryMB = avg(todouble(MemoryWorkingSetBytes_d)) / 1048576,
    MaxMemoryMB = max(todouble(MemoryWorkingSetBytes_d)) / 1048576
  by bin(TimeGenerated, 1h)
| order by TimeGenerated desc

 

Step 2 — Update resource allocations based on what you observed.

az containerapp update 
  --name my-dotnet-api 
  --resource-group my-rg 
  --cpu 0.5 
  --memory 1.0Gi

Container Apps has specific valid CPU/memory combinations. The valid pairs are: `0.25 vCPU / 0.5 Gi`, `0.5 vCPU / 1.0 Gi`, `0.75 vCPU / 1.5 Gi`, `1.0 vCPU / 2.0 Gi`, and up to `4.0 vCPU / 8.0 Gi`. You cannot mix arbitrary values.

 

Step 3 — Configure .NET GC for server workloads.

 

By default, .NET uses the workstation GC mode which is tuned for interactive applications. For server containers, use server GC mode and configure the heap size appropriately:

// In runtimeconfig.template.json or via environment variables
{
  "configProperties": {
    "System.GC.Server": true,
    "System.GC.HeapHardLimit": 805306368,
    "System.GC.HighMemoryPercent": 75
  }
}

 

Or as environment variables in your Container App:

 

az containerapp update 
  --name my-dotnet-api 
  --resource-group my-rg 
  --set-env-vars 
    "DOTNET_GCConserveMemory=5" 
    "DOTNET_GCHeapHardLimit=805306368"

 

 Measuring the Impact of Your Changes

 

After making changes, use this Log Analytics query to track your startup times over the past 24 hours and confirm the improvements:

ContainerAppConsoleLogs_CL
| where ContainerAppName_s == "my-dotnet-api"
| where Log_s contains "Application started" or Log_s contains "Now listening on"
| project TimeGenerated, Log_s, ContainerName_s
| order by TimeGenerated desc

 

And check request duration percentiles in Application Insights:

requests
| where cloud_RoleName == "my-dotnet-api"
| where timestamp > ago(24h)
| summarize
    p50 = percentile(duration, 50),
    p90 = percentile(duration, 90),
    p99 = percentile(duration, 99),
    count = count()
  by bin(timestamp, 1h)
| order by timestamp desc

 

Summary: Your Performance Tuning Quick Reference

 

Here is a quick decision guide based on what you are seeing:

SymptomMost Likely CauseFirst Fix to Try
First request after idle is very slow`minReplicas: 0`Set `minReplicas: 1`
Spike period has errors, then recoversKEDA scaling too slowLower concurrentRequests threshold
New replicas start slowlyLarge image sizeMulti-stage Docker build
High CPU at moderate trafficUnder-allocated resourcesIncrease CPU/memory allocation
Django startup is slowHeavy Python importsProfile and defer imports
.NET app slow under loadWorkstation GC modeEnable server GC

References and Sample Resources

Use these links to tune startup performance, scaling behavior, and runtime efficiency.

Azure Container Apps docs (core)
Runtime and framework performance references
Scaling engine references and samples

What's Next

 

In Part 3, we go deeper into the most specialized and complex scenario in this series: troubleshooting AI workloads in Azure Container Apps. Loading large ML models, managing GPU and CPU resource constraints, and dealing with memory pressure from inference workloads all require techniques that go beyond standard web application troubleshooting.

 

Part of the series: Troubleshooting Azure Container Apps in Production

Next: Part 3 — Troubleshooting ML Model Loading, GPU Issues, and Memory Pressure in Azure Container Apps

Updated May 18, 2026
Version 1.0
No CommentsBe the first to comment