When AWS App Runner Beats Lambda for Web Workloads

A data-driven comparison of App Runner vs Lambda for HTTP APIs — where container-based serverless wins on latency, cost, and developer experience.

#aws#app-runner#lambda#serverless
Cover image for the article: When AWS App Runner Beats Lambda for Web Workloads

The False Binary of Serverless

The serverless community has a Lambda bias. Every architecture diagram starts with API Gateway + Lambda as the default compute layer, and teams only consider alternatives when they hit Lambda's sharp edges: cold starts, execution time limits, payload size constraints, or the impedance mismatch of shoving a web framework into a function handler.

AWS App Runner occupies the space between Lambda and ECS Fargate — fully managed container hosting with automatic scaling, no cluster management, and a per-request pricing model that competes with Lambda for sustained traffic. It's the right choice more often than most teams realize.

App Runner vs Lambda Decision Tree

Architecture: Where Each Service Excels

Lambda's sweet spot:

  • Event-driven triggers (S3, SQS, DynamoDB Streams, EventBridge)
  • Sub-second execution tasks
  • Highly variable traffic with long idle periods
  • Glue logic between AWS services

App Runner's sweet spot:

  • HTTP APIs with sustained traffic (>1 req/sec average)
  • Workloads requiring persistent connections (database pools, WebSocket, gRPC)
  • Applications built on web frameworks (Express, FastAPI, Spring Boot)
  • Services that need more than 15 minutes execution time
  • Workloads sensitive to cold start latency

The Cold Start Problem — Quantified

Cold starts are Lambda's most discussed limitation, but the conversation often lacks data. Here's what we measured across our API fleet:

// Benchmark: Cold start measurement across configurations
// Tested with 1000 cold invocations per configuration

interface ColdStartResult {
  runtime: string;
  memoryMb: number;
  bundleSizeMb: number;
  p50ColdStartMs: number;
  p99ColdStartMs: number;
  withProvisionedConcurrency: number;
}

const results: ColdStartResult[] = [
  {
    runtime: 'nodejs20.x',
    memoryMb: 256,
    bundleSizeMb: 2.1,
    p50ColdStartMs: 180,
    p99ColdStartMs: 890,
    withProvisionedConcurrency: 0, // Eliminated but costs $$$
  },
  {
    runtime: 'nodejs20.x',
    memoryMb: 1024,
    bundleSizeMb: 2.1,
    p50ColdStartMs: 95,
    p99ColdStartMs: 340,
    withProvisionedConcurrency: 0,
  },
  {
    runtime: 'nodejs20.x',
    memoryMb: 1024,
    bundleSizeMb: 45, // Heavy deps: prisma, etc.
    p50ColdStartMs: 1200,
    p99ColdStartMs: 3400,
    withProvisionedConcurrency: 0,
  },
];

App Runner has no cold starts for active services. When traffic drops to zero, App Runner scales to a minimum of 1 instance (configurable), meaning the first request after idle hits a warm container. The minimum cost is ~$5/month for that warm instance.

Benchmarks: Head-to-Head Comparison

We ran both services behind the same API for 30 days. The API serves product catalog data from DynamoDB with moderate response sizes (2-8KB):

MetricLambda (1024MB)App Runner (1 vCPU, 2GB)Winner
p50 Latency12ms8msApp Runner
p99 Latency340ms (cold starts)45msApp Runner
p99.9 Latency890ms120msApp Runner
Monthly cost @ 100 req/sec avg$892$634App Runner (-29%)
Monthly cost @ 1 req/min avg$18$42Lambda (-57%)
Monthly cost @ 0.1 req/min avg$2$42Lambda (-95%)
Deploy time15-45 sec2-4 minLambda
Max concurrent connections1000 (account limit)200 per instance (auto-scales)App Runner
Database connection poolingExternal (RDS Proxy)In-processApp Runner

The crossover point is clear: Lambda wins for sparse, bursty traffic; App Runner wins for sustained load above ~1 request/second.

Database Connection Pooling: The Hidden Killer

Lambda's most painful architectural constraint for web APIs is database connections. Each Lambda instance opens its own connection, and with 100+ concurrent instances, you'll exhaust PostgreSQL's connection limit:

// Lambda approach: requires RDS Proxy ($$$) or connection limit workarounds
// Each instance maintains its own connection — no pooling across instances

// App Runner approach: standard connection pooling
import { Pool } from 'pg';

const pool = new Pool({
  host: process.env.DB_HOST,
  port: 5432,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 20, // Pool size per instance
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});

// Works exactly like a traditional web server
app.get('/products/:id', async (req, res) => {
  const client = await pool.connect();
  try {
    const result = await client.query(
      'SELECT * FROM products WHERE id = $1',
      [req.params.id]
    );
    res.json(result.rows[0]);
  } finally {
    client.release();
  }
});

With App Runner, each instance maintains a connection pool. At 5 instances with 20 connections each, you use 100 database connections — predictable, manageable, and no RDS Proxy required (saving $70-200/month depending on instance size).

App Runner Configuration for Production

A production-ready App Runner service configuration via CDK:

import * as apprunner from '@aws-cdk/aws-apprunner-alpha';

const service = new apprunner.Service(this, 'ApiService', {
  source: apprunner.Source.fromEcr({
    imageConfiguration: {
      port: 8080,
      environmentVariables: {
        NODE_ENV: 'production',
        DB_HOST: database.instanceEndpoint.hostname,
      },
    },
    repository: ecr.Repository.fromRepositoryName(this, 'Repo', 'api-service'),
    tagOrDigest: 'latest',
  }),
  cpu: apprunner.Cpu.ONE_VCPU,
  memory: apprunner.Memory.TWO_GB,
  autoDeploymentsEnabled: true,
  healthCheck: apprunner.HealthCheck.http({
    path: '/health',
    interval: cdk.Duration.seconds(10),
    timeout: cdk.Duration.seconds(5),
    healthyThreshold: 2,
    unhealthyThreshold: 3,
  }),
  instanceConfiguration: {
    instanceRoleArn: serviceRole.roleArn,
  },
  autoScalingConfiguration: new apprunner.AutoScalingConfiguration(this, 'Scaling', {
    maxConcurrency: 100, // Requests per instance before scaling
    maxSize: 25,
    minSize: 2, // Always keep 2 warm instances
  }),
  vpcConnector: new apprunner.VpcConnector(this, 'VpcConnector', {
    vpc,
    vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
    securityGroups: [serviceSecurityGroup],
  }),
});

The Decision Framework

After running both patterns across multiple services, here's our decision tree:

  1. Is the trigger non-HTTP? (SQS, S3, EventBridge, etc.) → Lambda. App Runner only handles HTTP.
  2. Is average traffic below 1 req/sec? → Lambda. You'll pay for idle App Runner capacity.
  3. Does the service need persistent connections? (DB pools, WebSocket, gRPC) → App Runner.
  4. Is cold start latency unacceptable? (user-facing APIs with strict SLAs) → App Runner.
  5. Is the service a standard web framework? (Express, FastAPI, etc.) → App Runner. No adapter layer needed.
  6. Is execution time > 15 minutes? → App Runner (or Step Functions for orchestration).

Operational Differences

App Runner advantages:

  • Standard Docker containers — same image runs locally and in production
  • Built-in observability (CloudWatch metrics, X-Ray integration)
  • VPC connectivity for private resources
  • Custom domains with managed TLS
  • No API Gateway costs (App Runner includes HTTP routing)

Lambda advantages:

  • Pay-per-invocation (true zero cost at zero traffic)
  • Native integration with 200+ AWS event sources
  • Sub-second deployments
  • Built-in retry and DLQ for async invocations
  • Fine-grained IAM per function

Conclusion

The serverless landscape isn't Lambda vs. containers anymore — it's about choosing the right abstraction for each workload. App Runner is serverless containers: no cluster management, automatic scaling, per-request-based costs (at scale), and zero cold starts.

For HTTP APIs with sustained traffic, App Runner delivers better latency at lower cost while simplifying your architecture (no API Gateway, no RDS Proxy, no Lambda adapter layers). For event-driven workloads and sparse traffic, Lambda remains the superior choice.

Stop defaulting to Lambda for everything. Match the compute model to the workload pattern, and your architecture will be simpler, cheaper, and faster.

Comments

    No comments yet. Be the first to share your thoughts.