Skip to content
Ayhan Sipahi Ayhan Sipahi

AWS Lambda Performance Optimization: Sub-10ms Latency

Hold AWS Lambda warm-path latency inside a 10 ms budget with runtime choice, connection reuse, bundle discipline, caching, and memory tuning.

Latency-critical Lambda workloads are won or lost on the warm path. Cold starts get the attention, but a function that answers a request in single-digit milliseconds is mostly a story about which runtime loads your handler, how many network handshakes happen per invocation, and how much memory the function was given.

The default worth starting from: a compiled runtime on the hot path, clients created outside the handler, DynamoDB or ElastiCache behind it, and memory picked with AWS Lambda Power Tuning instead of guessed. Node.js can reach the same range, but it needs stricter bundle discipline to get there.

Where the Milliseconds Go

Take a pricing or risk endpoint that has to answer thousands of times per second. The team is moving it off an on-premises service that already responds in low single-digit milliseconds, so serverless only counts as a success if it lands in the same range. That constraint changes which defaults are acceptable.

A first Lambda implementation usually loses time in four places, and only one of them is the code you wrote:

  • Init phase: everything the runtime parses and evaluates before the handler runs, which scales with package size
  • Per-invocation handshakes: a fresh TCP, TLS, and auth round trip to the database on every request
  • Runtime overhead: an interpreted runtime pays initialization cost that a static binary does not
  • Under-provisioned memory: Lambda scales CPU with memory, so a small memory setting throttles compute

Each of those has a specific fix, and they compound in that order.

Runtime Selection

Cold Start Profiles by Runtime

Runtime choice shows up first in how much work happens before your code runs. The open lambda-perf project measures that work on a schedule. Its README describes grabbing every function fresh from S3 each day, deploying it, and invoking it ten times as cold starts, with the Init Duration from each REPORT log line collected into a daily JSON file committed to the repository. The published matrix spans 128 MB to 1,024 MB on both arm64 and x86_64.

Averaging the ten init durations in the lambda-perf file dated 13 August 2026, at 1,024 MB on arm64:

RuntimeInit duration, mean of 10 cold startsMain trade-off
Rust on provided.al202313.87 ms (12.28-14.72)Slowest to write; smallest ecosystem for AWS glue code
Go on provided.al202342.00 ms (35.41-45.88)Single static binary; goroutines make parallel I/O cheap
Python 3.1376.85 ms (63.87-85.63)Small dependency sets stay fast; large wheels dominate init
Node.js 24112.12 ms (93.53-129.86)Best ecosystem; init cost tracks bundle size almost linearly
.NET 10, managed228.14 msNative AOT changes the row: dotnet10 AOT lands at 62.82 ms in the same file
Java 21, managed243.96 msSnapStart exists specifically to close this gap

That table needs four qualifications before it turns into a shortlist.

The measured workload is a hello-world handler, so those figures are runtime floors rather than application numbers. Everything your package parses during INIT lands on top of them, which is why bundle size moves the result further than the runtime label does.

Warm execution is not in the table because it is not in the dataset. Every invocation in that benchmark is a cold start by design, so the file measures the init phase and never reaches a steady state. Ranking these runtimes on the warm path takes a different run: the same handler invoked repeatedly into a reused execution environment, reading Duration from the REPORT line instead of Init Duration.

Absolute values drift, and the ordering does not. Across the daily files for 15 and 30 July and 6 and 13 August 2026, Rust ranges from 12.83 ms to 22.16 ms and managed Java 21 from 225.83 ms to 270.57 ms, while the ordering (Rust first, then Go, Python, Node.js, and managed Java and .NET last) holds on all four days. A dated file is quotable; none of this is a specification.

arm64 leads x86_64 on every row of that day’s file, where the same runtimes report 16.12 ms for Rust, 49.73 ms for Go, 85.14 ms for Python 3.13, 147.38 ms for Node.js 24 and 263.25 ms for Java 21. The benchmark’s site labels the region as us-east-1, and the repository keeps its deploy region in a GitHub Actions secret, so that label is the only statement of it.

One mitigation is missing from the dataset entirely, because lambda-perf publishes no SnapStart figures. AWS documents SnapStart as reducing startup latency “from several seconds to as low as sub-second, in optimal scenarios”, and it now covers Java 11 and later, Python 3.12 and later, and .NET 8 and later, so it is no longer a JVM-only escape hatch. The same page describes provisioned concurrency as keeping functions “initialized and ready to respond in double-digit milliseconds”, which is what skipping the init phase costs rather than what shortening it costs.

Go is the default for the hot path. In that same file its init floor sits at roughly three times Rust’s and about a sixth of managed Java 21’s, and it comes with an AWS SDK and a hiring pool that a mixed team can work in:

// Go's concurrency model is perfect for Lambda
func handler(ctx context.Context, event events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    start := time.Now()
    
    // Parallel I/O operations - this is where Go shines
    var wg sync.WaitGroup
    results := make(chan Result, 3)
    
    // Fetch user data
    wg.Add(1)
    go func() {
        defer wg.Done()
        user, err := fetchUser(ctx, event.PathParameters["userID"])
        results <- Result{Data: user, Err: err, Source: "user"}
    }()
    
    // Fetch from cache
    wg.Add(1) 
    go func() {
        defer wg.Done()
        cached, err := getFromCache(ctx, "portfolio:"+event.PathParameters["userID"])
        results <- Result{Data: cached, Err: err, Source: "cache"}
    }()
    
    // Fetch market data
    wg.Add(1)
    go func() {
        defer wg.Done()
        market, err := getMarketData(ctx)
        results <- Result{Data: market, Err: err, Source: "market"}
    }()
    
    // Collect results with timeout protection
    go func() {
        wg.Wait()
        close(results)
    }()
    
    response := buildResponse(results)
    
    // Warm execution is set by the slowest of the three calls, not their sum
    log.Printf("Total execution: %v", time.Since(start))
    return response, nil
}

Moving a hot path from Node.js to Go can show up twice on the bill: in billed duration, and in the memory the function needs to hit the same latency target. Measure both before and after, because the second one is the effect teams forget to claim.

Database Optimization

Connection Reuse Across Invocations

The most common mistake is treating Lambda functions like traditional web servers. Each invocation establishes new database connections:

// Bad: a fresh handshake on every invocation
export const handler = async (event) => {
  // New connection every time = TCP + TLS + auth round trip per request
  const db = await createConnection({
    host: process.env.DB_HOST,
    // ... connection config
  });
  
  const result = await db.query('SELECT * FROM trades WHERE id = ?', [event.id]);
  await db.close(); // Closing connection = waste
  
  return { statusCode: 200, body: JSON.stringify(result) };
};

The fix is to move connection initialization outside the handler:

// Good: connection reuse across warm invocations
import mysql from 'mysql2/promise';

// Initialize connection outside handler - reused across invocations
let connection: mysql.Connection;

const getConnection = async () => {
  if (!connection) {
    connection = await mysql.createConnection({
      host: process.env.DB_HOST,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      database: process.env.DB_NAME,
      // mysql2 spells these enableKeepAlive and connectTimeout;
      // keepAlive, acquireTimeout and timeout are silently ignored here
      enableKeepAlive: true,
      keepAliveInitialDelay: 0,
      connectTimeout: 1000 // Fail fast: a slow connect must not become the latency
    });
  }
  return connection;
};

export const handler = async (event) => {
  const start = Date.now();
  
  try {
    const db = await getConnection();
    const result = await db.execute('SELECT * FROM trades WHERE id = ?', [event.id]);
    
    console.log(`Query executed in ${Date.now() - start}ms`);
    return { statusCode: 200, body: JSON.stringify(result) };
  } catch (error) {
    // Connection retry logic here
    return { statusCode: 500, body: 'Database error' };
  }
};

The handshake now happens once per execution environment instead of once per request, and what is left on the warm path is the query plus one network hop. The trade-off is liveness: a reused connection can go stale between invocations, so either check it before use or put RDS Proxy in front and let it own the pool.

Database Choice

For a latency-critical read path the shortlist is three AWS options, and the honest way to compare them is by what AWS measures rather than by what it advertises:

OptionWhat AWS documents or measuresStrengthsWhat it costs you
DynamoDBSingle-digit millisecond average for single-item reads, network excludedConnection handling lives in the SDK, reachable without a VPCLimited query shapes; eventually consistent by default
Aurora Serverless v2 behind RDS ProxyThe proxy adds low single-digit milliseconds per queryFull SQL, ACID, familiar toolingConnection management, VPC attachment, one extra network hop per query
ElastiCachep50 GET around 751 microseconds in AWS’s own scaling testHighest throughput of the threeCache invalidation, and it pulls the function into a VPC

Every cell there carries a qualification that decides whether it survives contact with a 10 ms budget.

The DynamoDB figure is a documented commitment rather than a slogan, and it is narrower than it looks. The DynamoDB latency troubleshooting guide scopes its single-digit millisecond Average SuccessfulRequestLatency to singleton operations, meaning a single item addressed by a fully specified primary key, and states that the metric covers only latency internal to the service: client-side activity and network trip times are excluded. The tail is public too. AWS’s write-up of request hedging at Global Payments reports a client-side p99 of 9.5 ms for GetItem with no hedging, dropping to 6.7 ms when a second request fires at the p80 delta, a 29% improvement bought with 8% duplicated requests. Move that delta to p50 and the improvement is 26% for 27% duplicates. The same write-up gives no region, item size, or load generator and describes a simulated environment, so 9.5 ms is an order of magnitude rather than a number to plan against.

Put the two together and the budget arithmetic gets uncomfortable: on a DynamoDB-backed path the datastore’s own tail can spend most of a 10 ms budget before the handler does anything. It also sets the shape of the alarm. A 10 ms threshold is a p95 statement, because at p99 the database alone can finish the budget.

ElastiCache is where marketing and measurement disagree by an order of magnitude. The ElastiCache product page advertises microsecond latency. AWS’s own measurement in the ElastiCache Serverless launch post reports p50 GET latency around 751 microseconds and never above 860 microseconds while scaling to 1 million requests per second with an 80/20 read/write mix and 512-byte values, with p50 SET around 1,050 microseconds and below 1,200 microseconds. Both can be true, because they measure different things. AWS’s guidance on monitoring server-side latency for ElastiCache for Valkey separates the engine-side metric, which covers preprocessing, command execution, and postprocessing only, from what the client observes, which also includes client resources and the network. Its own diagnostic follows from that split: client latency rising while the server-side metric stays flat points away from the engine. Sub-millisecond is the number to budget with, and microseconds describe the engine rather than the round trip.

Aurora Serverless v2 is usually chosen for query shape rather than latency, and the RDS Proxy that makes it usable from Lambda has a documented price. AWS’s RDS Proxy guidance says you typically observe added latency in the low single-digit milliseconds, and it names exactly this workload class as the one that notices: an application running single-digit millisecond or sub-millisecond queries feels the extra network hop, because the hop is large next to the query itself.

Default: DynamoDB for primary data, with ElastiCache in front of it only when the read path is hot enough to justify the VPC attachment. That last clause is the trade-off teams skip. DynamoDB is reachable over the public AWS endpoint; ElastiCache is not, so adding a cache also adds VPC networking and ENI lifecycle to the function. If the hot path is DynamoDB reads specifically, DAX is the narrower version of the same bet: AWS documents up to a 10x improvement, from milliseconds to microseconds, with the same VPC requirement attached.

An optimized DynamoDB read looks like this:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";

// Initialize client outside handler
const client = new DynamoDBClient({
  region: process.env.AWS_REGION,
  maxAttempts: 2, // Fail fast for low latency
});

const docClient = DynamoDBDocumentClient.from(client, {
  marshallOptions: {
    removeUndefinedValues: true,
  },
});

export const getTradeData = async (tradeId: string) => {
  const start = Date.now();
  
  try {
    const response = await docClient.send(
      new GetCommand({
        TableName: "Trades",
        Key: { tradeId },
        ConsistentRead: true // Twice the read capacity, plus a latency cost
      })
    );
    
    const latency = Date.now() - start;
    console.log(`DynamoDB read: ${latency}ms`);
    
    return response.Item;
  } catch (error) {
    console.error(`DynamoDB error after ${Date.now() - start}ms:`, error);
    throw error;
  }
};

Bundle Size and the Init Phase

Bundle size is not a vanity metric on Lambda. Everything in the package is read, parsed, and evaluated during INIT before the handler is called, so a multi-megabyte Node.js bundle pays for itself on every cold start.

ESBuild Configuration

esbuild is the practical default for Lambda bundling: fast enough to run on every build, and its external handling maps cleanly onto what the managed runtime already provides.

// esbuild.config.js
const esbuild = require('esbuild');

const config = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  minify: true,
  target: 'node20',
  format: 'esm', // ES modules for better tree-shaking
  platform: 'node',
  outfile: 'dist/index.mjs',

  // Critical optimizations
  external: [
    '@aws-sdk/*', // Let the managed runtime provide AWS SDK v3
    'aws-sdk'  // v2 is end-of-support; never ship it
  ],

  treeShaking: true,
  mainFields: ['module', 'main'], // Prefer ES modules

  // metafile is what makes output sizes readable: result.outputFiles
  // stays empty unless you also set write: false
  metafile: true,
  sourcemap: 'external',
};

esbuild.build(config)
  .then((result) => {
    const [, output] = Object.entries(result.metafile.outputs)
      .find(([file]) => file.endsWith('.mjs'));

    console.log(`Bundle size: ${(output.bytes / 1024).toFixed(2)}KB`);

    // Fail the build before the bundle ever reaches Lambda
    if (output.bytes > 500 * 1024) { // 500KB limit
      throw new Error(`Bundle too large: ${(output.bytes / 1024).toFixed(2)}KB`);
    }
  })
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Marking @aws-sdk/* external keeps the bundle small but hands SDK versioning to the runtime. If you need a reproducible SDK version across deployments, bundle it and accept the extra kilobytes.

AWS SDK v3: Modular Architecture Benefits

SDK v3 splits the monolith into per-service clients, so the bundle carries only the calls you make:

// Bad: v2 pulls in the whole SDK surface, and it has reached end-of-support
import AWS from 'aws-sdk';
const dynamodb = new AWS.DynamoDB.DocumentClient();

// Good: New way - only import what you need
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";

const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client);

Bundle work pays off in the INIT phase, which CloudWatch reports separately as Init Duration. Compare that field before and after; it is the only part of the invocation that bundle size controls.

Caching Strategy

ElastiCache sits in front of the hot read path. The shape that matters is the singleton: one client per execution environment, created at module scope, never inside the handler.

import Redis from 'ioredis';

// Connection singleton - critical for performance
let redis: Redis | null = null;

const getRedisConnection = (): Redis => {
  if (!redis) {
    redis = new Redis({
      host: process.env.REDIS_ENDPOINT,
      port: 6379,
      
      // Fail fast: a slow cache must not become the latency
      connectTimeout: 1000,
      commandTimeout: 500,
      maxRetriesPerRequest: 2,  // Don't retry forever

      keepAlive: 30000,  // Keep connections alive
      lazyConnect: true,  // Connect on first use
      family: 4, // Use IPv4
      db: 0,
    });
    
    // Connection event logging for monitoring
    redis.on('connect', () => console.log('Redis connected'));
    redis.on('error', (err) => console.error('Redis error:', err));
  }
  
  return redis;
};

// Cache-aside pattern with performance monitoring
export const getCachedData = async (key: string, ttl = 300): Promise<any> => {
  const start = Date.now();
  
  try {
    const cached = await getRedisConnection().get(key);
    const cacheLatency = Date.now() - start;
    
    console.log(`Cache lookup: ${cacheLatency}ms`);
    
    if (cached) {
      // Cache hit - this should be <1ms
      return JSON.parse(cached);
    }
    
    // Cache miss - fetch from database
    const data = await fetchFromDatabase(key);
    
    // Set cache asynchronously to not block response
    getRedisConnection()
      .setex(key, ttl, JSON.stringify(data))
      .catch(err => console.error('Cache set error:', err));
    
    return data;
    
  } catch (error) {
    const errorLatency = Date.now() - start;
    console.error(`Cache error after ${errorLatency}ms:`, error);
    
    // Fallback to database on cache failure
    return await fetchFromDatabase(key);
  }
};

// High-performance batch operations
export const batchGetCached = async (keys: string[]): Promise<Record<string, any>> => {
  const start = Date.now();
  
  try {
    const results = await getRedisConnection().mget(...keys);
    console.log(`Batch cache lookup (${keys.length} keys): ${Date.now() - start}ms`);
    
    const parsed: Record<string, any> = {};
    keys.forEach((key, index) => {
      if (results[index]) {
        parsed[key] = JSON.parse(results[index]);
      }
    });
    
    return parsed;
    
  } catch (error) {
    console.error(`Batch cache error:`, error);
    return {};
  }
};

Two things matter more than the client options. First, the connection has to survive between invocations. If it does not, every read pays a fresh TCP round trip and the cache ends up slower than the database it was meant to protect. Second, a cache read still crosses the network, so the floor is your VPC round trip rather than Redis itself.

ElastiCache Configuration

A minimal cluster definition, placed in the same subnets as the function:

# CloudFormation template for the Redis setup
ElastiCacheSubnetGroup:
  Type: AWS::ElastiCache::SubnetGroup
  Properties:
    Description: Subnet group for Lambda Redis access
    SubnetIds: 
      - !Ref PrivateSubnet1
      - !Ref PrivateSubnet2

ElastiCacheCluster:
  Type: AWS::ElastiCache::CacheCluster
  Properties:
    CacheNodeType: cache.r6g.large  # Memory optimized
    Engine: redis
    EngineVersion: 7.0
    NumCacheNodes: 1
    VpcSecurityGroupIds:
      - !Ref RedisSecurityGroup
    CacheSubnetGroupName: !Ref ElastiCacheSubnetGroup
    
    # Performance optimizations
    PreferredMaintenanceWindow: sun:03:00-sun:04:00
    SnapshotRetentionLimit: 1
    SnapshotWindow: 02:00-03:00

Memory and CPU

Lambda allocates CPU in proportion to memory. A function reaches the equivalent of one full vCPU at 1,769 MB; below that it runs on a fraction of a core. On a CPU-bound handler, raising memory is not a memory decision at all. It is the only CPU dial Lambda exposes.

That makes the usual cost intuition backwards. Take 1M invocations a month on x86 in us-east-1, where the AWS Lambda pricing page lists 0.20permillionrequestsand0.20 per million requests and 0.0000166667 per GB-second, with the free tier left out of the arithmetic:

  • 512 MB at 20 ms billed per invocation: 0.5 GB × 0.02 s × 1M = 10,000 GB-seconds, so 0.17ofdurationplus0.17 of duration plus 0.20 of requests, $0.37 in total
  • 1,024 MB at 10 ms billed per invocation: 1 GB × 0.01 s × 1M = 10,000 GB-seconds, the same $0.37 in total

Doubling memory is free whenever it halves duration, and a net saving whenever duration falls by more than half. Below that break-even the extra memory costs real money. The break-even is the number to go looking for. A memory setting someone else published will not transfer to your handler.

AWS Lambda Power Tuning

Power Tuning finds that break-even for a specific function instead of a generic one. It ships as a Serverless Application Repository app that deploys a Step Functions state machine, so you start an execution rather than invoke a Lambda:

# Deploy once from the Serverless Application Repository, then:
aws stepfunctions start-execution \
  --state-machine-arn arn:aws:states:us-east-1:123456789012:stateMachine:powerTuningStateMachine \
  --input '{
    "lambdaARN": "arn:aws:lambda:us-east-1:123456789012:function:my-function",
    "powerValues": [128, 256, 512, 1024, 1536, 2048],
    "num": 50,
    "payload": {"test": "data"},
    "parallelInvocation": true,
    "strategy": "cost"
  }'

strategy is the part worth thinking about. cost optimizes the bill, speed optimizes latency, balanced splits the difference. For a latency budget, run it with speed and then check whether the winning power value still sits inside the cost break-even above.

VPC Networking

The advice to keep Lambda functions out of a VPC dates from before September 2019, when every function attached its own ENI and a cold start inside a VPC could take ten seconds or more. AWS replaced that model with shared Hyperplane ENIs created once per subnet and security group combination, and the per-function attachment cost went away.

What remains is not zero. A VPC-attached function still cannot reach the public internet without a NAT gateway or a VPC endpoint, and the first function in a new subnet and security group pair still waits for ENI creation. But VPC attachment is no longer a reason to avoid ElastiCache or RDS on a latency-sensitive path.

HTTP Keep-Alive

Every AWS SDK call is an HTTPS request, and without connection reuse each one pays a TLS handshake. The AWS SDK for JavaScript v3 reuses TCP connections by default, so the reason to configure an agent explicitly is to control socket count and timeouts:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { NodeHttpHandler } from "@smithy/node-http-handler";
import { Agent } from "node:https";

// AWS endpoints are HTTPS, so this has to be httpsAgent, not httpAgent
const httpsAgent = new Agent({
  keepAlive: true,
  maxSockets: 50
});

const dynamoClient = new DynamoDBClient({
  region: process.env.AWS_REGION,
  maxAttempts: 2,
  requestHandler: new NodeHttpHandler({
    httpsAgent,
    connectionTimeout: 1000,
    requestTimeout: 2000
  })
});

On SDK v2 the equivalent was the AWS_NODEJS_CONNECTION_REUSE_ENABLED=1 environment variable, which is worth checking on any function still running v2 code.

Reuse only helps while a connection is still there. AWS’s DynamoDB latency troubleshooting guidance notes that the first request on a new connection is slower than the ones that reuse it. Its suggested remedy is a keep-alive GetItem every 30 seconds when no other requests are made. That assumes a client which keeps running between requests, and a Lambda function is not one. Lambda freezes the execution environment once the invocation returns, so nothing inside it fires on a timer while the path is idle. A scheduled warmer does not recover the idea either: it adds request cost, and it cannot pick the environment whose connection you wanted kept. On a path that goes quiet between bursts, the first request after the gap pays the handshake, and the latency budget has to allow for it.

Monitoring and Alerting

Custom CloudWatch Metrics

The built-in Duration metric covers the whole invocation, which is not enough to tell a slow query from a slow cold start. Custom dimensions close that gap:

import { CloudWatch } from '@aws-sdk/client-cloudwatch';

const cloudwatch = new CloudWatch({});

export const trackPerformanceMetrics = async (
  functionName: string,
  operationType: string,
  duration: number,
  cacheHit: boolean,
  success: boolean
) => {
  const metrics = [
    {
      MetricName: 'ResponseTime',
      Value: duration,
      Unit: 'Milliseconds',
      Dimensions: [
        { Name: 'FunctionName', Value: functionName },
        { Name: 'OperationType', Value: operationType },
        { Name: 'Success', Value: success.toString() }
      ]
    },
    {
      MetricName: 'CacheHitRate', 
      Value: cacheHit ? 1 : 0,
      Unit: 'Count',
      Dimensions: [
        { Name: 'FunctionName', Value: functionName },
        { Name: 'OperationType', Value: operationType }
      ]
    }
  ];

  await cloudwatch.putMetricData({
    Namespace: 'Lambda/Performance',
    MetricData: metrics
  });
};

// Usage in Lambda function
export const handler = async (event, context) => {
  const start = Date.now();
  let cacheHit = false;
  let success = false;
  
  try {
    // Your function logic here
    const { payload, servedFromCache } = await processRequest(event);
    cacheHit = servedFromCache;
    success = true;
    
    return { statusCode: 200, body: JSON.stringify(payload) };
    
  } catch (error) {
    console.error('Function error:', error);
    return { statusCode: 500, body: 'Internal error' };
    
  } finally {
    const duration = Date.now() - start;
    
    // Track metrics asynchronously 
    trackPerformanceMetrics(
      context.functionName,
      event.operationType || 'default',
      duration,
      cacheHit,
      success
    ).catch(err => console.error('Metrics error:', err));
  }
};

One caveat: PutMetricData is itself a network call. On a function with a single-digit millisecond budget, prefer the CloudWatch Embedded Metric Format, which writes structured JSON to stdout and lets the log pipeline extract the metrics. Same dimensions, no API call on the request path.

CloudWatch Alarms for the 10 ms Latency Budget

# CloudWatch alarm configuration
HighLatencyAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    AlarmName: !Sub "${FunctionName}-High-P95-Latency"
    AlarmDescription: "Lambda P95 latency exceeded 10ms"
    
    MetricName: Duration
    Namespace: AWS/Lambda
    ExtendedStatistic: p95 # Statistic: Average would hide the tail
    Period: 60
    EvaluationPeriods: 2
    Threshold: 10 # 10ms threshold
    ComparisonOperator: GreaterThanThreshold
    
    Dimensions:
      - Name: FunctionName
        Value: !Ref LambdaFunction
    
    AlarmActions:
      - !Ref PerformanceAlertTopic

# Custom dashboard for performance monitoring
PerformanceDashboard:
  Type: AWS::CloudWatch::Dashboard
  Properties:
    DashboardName: !Sub "${FunctionName}-Performance"
    DashboardBody: !Sub |
      {
        "widgets": [
          {
            "type": "metric",
            "properties": {
              "metrics": [
                [ "Lambda/Performance", "ResponseTime", "FunctionName", "${FunctionName}" ]
              ],
              "period": 60,
              "stat": "p95",
              "region": "${AWS::Region}",
              "title": "Response Time (P95)"
            }
          }
        ]
      }

Common Pitfalls

Bundle Regressions Through Dependencies

Bundle size is not a one-time fix. A single dependency that imports the CommonJS build of a library defeats tree-shaking for that whole library, and an automated dependency update can reintroduce it without anyone reading the diff.

Root cause pattern: Adding lodash instead of lodash-es pulls in the entire utility library.

Solution: Bundle size gates in the CI/CD pipeline:

# GitHub Actions workflow check
- name: Check bundle size
  run: |
    BUNDLE_SIZE=$(stat -c%s "dist/index.js")
    BUNDLE_SIZE_KB=$((BUNDLE_SIZE / 1024))
    echo "Bundle size: ${BUNDLE_SIZE_KB}KB"
    
    if [ $BUNDLE_SIZE_KB -gt 500 ]; then
      echo "Bundle too large: ${BUNDLE_SIZE_KB}KB > 500KB limit"
      exit 1
    fi

Redis Connection Lifecycle

A cache with a high hit rate can still be slow if every invocation opens a new connection. The symptom is a hit rate that looks healthy sitting next to cache latency that looks like a database.

Two things break the singleton. A client created inside the handler is obviously new every time. Less obviously, a process.on('beforeExit') handler that disconnects the client runs when the event loop drains after an invocation, closing exactly the connection the next invocation was supposed to reuse.

Solution: Reconnect only when the client is genuinely unusable:

// Module scope: created once per execution environment
let redis: Redis | null = null;

const getRedisConnection = (): Redis => {
  // 'end' means the client is finished. Checking for !== 'ready' would
  // rebuild the client while it is still connecting.
  if (!redis || redis.status === 'end') {
    redis = new Redis({
      // configuration
    });
  }
  return redis;
};

Consistency Chosen Per Access Path

Using eventual consistency for all DynamoDB reads to maximize performance works until a race condition surfaces: users see stale trade data during high-frequency updates.

Solution: Selective strong consistency for critical paths:

// Performance vs consistency decision matrix
const consistencyConfig = {
  userProfile: { consistentRead: false }, // Eventually consistent OK
  tradeData: { consistentRead: true },  // Strong consistency required
  marketData: { consistentRead: false },  // Eventually consistent OK
  balances: { consistentRead: true }  // Strong consistency required
};

const getTradeData = async (tradeId: string) => {
  return await docClient.send(
    new GetCommand({
      TableName: "Trades",
      Key: { tradeId },
      ConsistentRead: consistencyConfig.tradeData.consistentRead
    })
  );
};

Where to Start

The order matters more than the list. Each step only pays off once the previous one is done:

  1. Move every client out of the handler. Database, cache, and SDK clients belong at module scope. Nothing else here matters while each invocation is still opening its own connections.
  2. Run Power Tuning before choosing memory. It replaces an argument with a number, and it usually raises the memory setting.
  3. Put a bundle size gate in CI. A threshold that fails the build is the only thing that keeps Init Duration from drifting back up.
  4. Pick the runtime per path. A Go function on the hot path can sit next to Node.js functions everywhere else; the choice does not have to be repository-wide.
  5. Add caching last. It is both the largest win and the largest source of correctness bugs, and it drags the function into a VPC.

Single-digit millisecond Lambda responses are a reachable target, and the work behind them is unglamorous: reuse connections, keep the bundle small, give the function enough CPU, cache the reads that deserve it. Every figure quoted here measures one component. Whether the whole path holds the budget is what the p95 Duration alarm is there to tell you. That default holds when latency is genuinely part of the product contract. Override it when it is not: on a path where 100 ms is fine, the same effort buys a rounding error, and provisioned concurrency plus a compiled runtime becomes a cost you are paying for nothing.

References

Related posts