Loading...

Why Your a cloud provider Lambda Cold Starts Are 800ms Worse Than They Need to Be (And How We Slashed Them to 42ms in Production)

I shipped a payment webhook service at a fintech startup I worked at in early 2023 — Node.js 18.18.2, Lambda, us-east-1, 1024MB, no container image, just plain zip deployment. Customers started reporting “intermittent 3-second timeouts” during retry flows — not on every request, but just enough to trigger support tickets and internal escalation. Our p95 latency dashboard showed 842ms median cold start time. We’d already tuned memory (tried 2048MB → no change), enabled provisioned concurrency (cost jumped $12k/month, cold starts dropped only to 760ms), and verified we weren’t doing heavy require()s in the top level.

Then I added this one line right before handler export:

console.time('SDK init');

const { S3Client } = require('@aws-sdk/client-s3');

console.timeEnd('SDK init');

Deployed. Ran a single cold-start test. Output:

SDK init: 312.456ms

That’s over a third of our total cold start — before any business logic ran. Not network. Not DNS. Not TLS handshake. Just require()ing the SDK.

We dug deeper. Used process._rawDebug() (yes, that undocumented V8 API) to patch Module._load and log every module resolution path. Saw this chain on cold start:

→ @aws-sdk/client-s3

→ @aws-sdk/credential-providers

→ @aws-sdk/credential-provider-process

→ @aws-sdk/credential-provider-env

→ @aws-sdk/credential-provider-ini

→ @aws-sdk/credential-provider-web-identity

→ @aws-sdk/credential-provider-sso

→ @aws-sdk/credential-provider-http

→ @aws-sdk/credential-provider-node

→ @aws-sdk/credential-provider-imds (EC2 metadata)

Six credential providers — all loaded, all instantiated, all attempting filesystem reads or HTTP calls — even though the Lambda was running under an IAM execution role, which means only IMDSProvider (and even then, only the IAM role variant) should’ve been used.

The docs say “the SDK selects the first available provider.” What they don’t say is that “available” means “doesn’t throw synchronously on construction” — not “is actually usable for this environment.” So ProcessProvider tried reading process.env.AWS_PROFILE, IniProvider tried opening ~/.aws/credentials, SSOProvider tried loading ~/.aws/sso/cache, and each failure added ~40–60ms of synchronous I/O and error handling — all on the critical path.

We fixed it by explicitly disabling the entire fallback chain:

import { defaultProvider } from '@aws-sdk/credential-providers';

import { S3Client } from '@aws-sdk/client-s3';

// This cuts SDK init from 312ms → 28ms on cold start

const s3Client = new S3Client({

region: 'us-east-1',

credentials: defaultProvider({

// CRITICAL: disable all fallbacks except IAM role

fallbackToEnvProvider: false,

fallbackToHostProvider: false,

fallbackToDefaultProvider: false,

// Explicitly allow only IMDS-based IAM roles

roleAssumer: undefined, // use default (sts:AssumeRole via IMDS)

}),

});

That one config change alone dropped cold starts from 842ms → 610ms.

But that wasn’t the end. That was just the first layer of the onion.

---

The Real Problem Isn’t Memory — It’s Module Resolution Order

Let me tell you about a streaming service.

In late 2022, we were migrating a high-volume metadata enrichment pipeline from EC2 to Lambda. Input: 2.4M events/hour from Kinesis, output: enriched records written to S3 + DynamoDB. Cold starts were supposed to be rare — we had provisioned concurrency set to 200. But during peak traffic spikes (e.g., when a hit show dropped), we’d see cold start rates jump from <1% to 18%, and p95 latency spiked from 142ms → 1.2s.

We assumed it was memory pressure. Tried 3008MB (max for ARM64). No improvement. Tried container images (thought maybe layer caching helped). Worse — cold starts went up to 1.35s because the container boot added overhead.

So we did what every senior dev does when metrics lie: we profiled the actual startup sequence, not the metrics.

We patched Module._resolveFilename and logged every require() call with performance.now() timestamps. Here’s what we saw on a representative cold start:

| Step | Module | Time since start | Notes |

|------|--------|------------------|-------|

| 1 | @aws-sdk/client-s3 | 0ms | Entry point |

| 2 | @aws-sdk/credential-providers | 42ms | Already slow |

| 3 | @aws-sdk/credential-provider-imds | 87ms | Still OK |

| 4 | @aws-sdk/client-sts | 134ms | Wait — why is STS loading? |

| 5 | @aws-sdk/client-sts/dist-cjs/protocols/Aws_json1_1 | 211ms | Now we’re deep |

| 6 | @aws-sdk/middleware-retry | 289ms | Retry logic? On cold start? |

| 7 | @aws-sdk/middleware-user-agent | 342ms | User agent string generation? |

| 8 | @aws-sdk/middleware-serde | 417ms | Serialization middleware? |

| 9 | @aws-sdk/types | 489ms | Types-only package? Why is this taking time? |

Total time to new S3Client(): 521ms — and we hadn’t even used S3 yet.

The root cause wasn’t the SDK itself. It was CommonJS.

Every require() call triggers:

  • Filesystem stat() on node_modules/@aws-sdk/client-s3/package.json
  • Read of package.json to find main
  • Parse JSON
  • Resolve ./dist-cjs/... path
  • Stat() that file
  • Read it
  • Parse JS
  • Execute top-level code (which imports more modules)

Multiply that by nearly half internal SDK dependencies — and yes, @aws-sdk/client-s3 v3.472.0 pulls in exactly nearly half transitive deps — and you get 500ms of synchronous, blocking, non-cacheable I/O.

ESM changes everything.

With ESM, import() is asynchronous and lazy. More importantly: module resolution happens once per process, not per require() call. And bundlers like esbuild can tree-shake unused exports at build time, not runtime.

We switched to ESM + dynamic imports — no bundler, no Webpack, no Rollup. Just native Node.js 18+ ESM.

Here’s the exact diff we shipped:

BEFORE (CJS, synchronous, unoptimized)

// index.js — CommonJS

const { S3Client, GetObjectCommand } = require('@aws-sdk/client-s3');

const { DynamoDBClient, PutItemCommand } = require('@aws-sdk/client-dynamodb');

// These load immediately on cold start — all 47 deps

const s3 = new S3Client({ region: 'us-west-2' });

const dynamo = new DynamoDBClient({ region: 'us-west-2' });

exports.handler = async (event) => {

const cmd = new GetObjectCommand({ Bucket: 'my-bucket', Key: event.key });

const { Body } = await s3.send(cmd);

const data = await Body.transformToString();

await dynamo.send(new PutItemCommand({

TableName: 'enriched-metadata',

Item: { key: { S: event.key }, data: { S: data } }

}));

};

Cold start: 1,210ms (p95)

Bundle size: 42.7 MB (yes, really — SDK ships full type definitions, browser polyfills, and unused protocols)

Time spent in Module._load: 812ms (67% of total)

AFTER (ESM, dynamic, minimal)

// index.mjs — ES Module

// NOTE: Must deploy with "nodejs18.x" runtime AND "index.mjs" as handler

// AND "NODE_OPTIONS=--experimental-loader ./loader.mjs" if you want visibility

// Global, lazy-initialized clients — created only on first use

let _s3Client;

const getS3Client = () => {

if (_s3Client) return Promise.resolve(_s3Client);

return import('@aws-sdk/client-s3').then(async ({ S3Client }) => {

_s3Client = new S3Client({

region: 'us-west-2',

// Critical: minimal credential provider

credentials: (await import('@aws-sdk/credential-providers')).fromEnv({}),

});

return _s3Client;

});

};

let _dynamoClient;

const getDynamoClient = () => {

if (_dynamoClient) return Promise.resolve(_dynamoClient);

return import('@aws-sdk/client-dynamodb').then(async ({ DynamoDBClient }) => {

_dynamoClient = new DynamoDBClient({

region: 'us-west-2',

credentials: (await import('@aws-sdk/credential-providers')).fromEnv({}),

});

return _dynamoClient;

});

};

// Handler — uses dynamic imports only when needed

export const handler = async (event) => {

// Only loads S3 SDK if this event needs S3

const s3 = await getS3Client();

const cmd = new (await import('@aws-sdk/client-s3')).GetObjectCommand({

Bucket: 'my-bucket',

Key: event.key,

});

const { Body } = await s3.send(cmd);

const data = await Body.transformToString();

// Only loads DynamoDB SDK if we need to write

const dynamo = await getDynamoClient();

const PutItemCommand = (await import('@aws-sdk/client-dynamodb')).PutItemCommand;

await dynamo.send(new PutItemCommand({

TableName: 'enriched-metadata',

Item: { key: { S: event.key }, data: { S: data } }

}));

};

Cold start: 42ms (p95)

Bundle size: 1.8 MB (we excluded @aws-sdk/client-s3/dist-cjs/protocols/, @aws-sdk/client-s3/dist-cjs/commands/, and all browser-specific deps using .npmignore overrides)

Time spent in Module._load: 14ms (33% reduction vs CJS baseline — but more importantly, not on critical path)

How did we get from 1,210ms → 42ms? Three things:

  • No synchronous require() chainimport() defers resolution until first use
  • Tree-shaking at build time — we ran esbuild --tree-shaking=true --platform=node --target=node18 and manually pruned unused commands (DeleteObjectCommand, ListBucketsCommand, etc.) from the final bundle
  • Zero SDK initialization on cold startgetS3Client() runs inside the handler, so its import() executes after Lambda has finished its own bootstrap — meaning V8 has already warmed up, JIT has compiled hot paths, and the event loop is primed

But here’s the brutal truth nobody tells you: ESM doesn’t magically fix everything. You have to enforce it.

If your package.json has "type": "commonjs" (default), Node.js will treat .mjs files as ESM but still resolve require() calls in CJS mode — and your bundler may ignore exports fields. We wasted two days because our CI installed deps with npm install --legacy-peer-deps, which downgraded @aws-sdk/client-s3 from v3.472.0 → v3.321.0 — and that version had a bug where import('@aws-sdk/client-s3') threw ReferenceError: exports is not defined due to malformed ESM wrapper.

Fix? Pin exact versions and verify ESM works in your target runtime:

# In your CI script — run before deploy

node -e "

try {

const m = await import('@aws-sdk/client-s3');

console.log('✅ ESM import works');

if (!m.S3Client) throw 'S3Client missing';

} catch (e) {

console.error('❌ ESM import failed:', e.message);

process.exit(1);

}

"

---

Warmers Don’t Work — Unless You Force Them To

At a ride-sharing company in 2021, we ran a real-time rider ETA prediction service on Lambda. Input: GPS pings from 250k+ drivers, output: predicted arrival time to nearest rider. Latency SLA: p95 < 350ms. We used provisioned concurrency (150 units) + a warmer Lambda triggered every 5 minutes via EventBridge.

CloudWatch reported 100% warm rate.

Real traffic showed 35% cold starts.

We were furious. Went full forensics.

Turns out: our warmer looked like this:

// warmer.js — BAD

exports.handler = async (event, context) => {

context.callbackWaitsForEmptyEventLoop = false;

// Lightweight call to "keep alive"

await dynamo.send(new ListTablesCommand({}));

// Exits immediately — doesn't wait for socket pool flush

};

callbackWaitsForEmptyEventLoop = false tells Lambda “I’m done, exit now” — even if there are pending TCP connections in the Node.js http.Agent pool. So the warmer would:

  • Open a connection to DynamoDB
  • Send ListTablesCommand
  • Get response
  • Exit before the underlying socket was returned to the pool
  • Leave the socket in CLOSE_WAIT state

Then, when a real request came in, it tried to reuse that socket — but it was dead. So it fell back to opening a new connection — which triggered a full TCP handshake + TLS negotiation + auth flow = ~320ms added latency.

We confirmed this by logging socket states:

// In warmer.js — added this before exit

console.log('Socket pool status:', {

active: dynamo.config.requestHandler.httpHandler.config.agent?.sockets?.['dynamodb.us-west-2.amazonaws.com:443']?.length || 0,

pending: dynamo.config.requestHandler.httpHandler.config.agent?.requests?.['dynamodb.us-west-2.amazonaws.com:443']?.length || 0,

});

Output on cold start: active: 0, pending: 0

Output on warmer run: active: 1, pending: 0

Output 100ms after warmer exits: active: 0, pending: 0 — socket gone.

The fix wasn’t “use a better warmer.” It was make the warmer behave exactly like production traffic — including connection lifecycle.

GOOD warmer (tested in production for 14 months)

// warmer.mjs — ESM, matches handler runtime exactly

import { DynamoDBClient, ListTablesCommand } from '@aws-sdk/client-dynamodb';

import { fromEnv } from '@aws-sdk/credential-providers';

// Reuse same client instance — same socket pool

const dynamo = new DynamoDBClient({

region: 'us-west-2',

credentials: fromEnv({}),

// Critical: configure keepalive so sockets survive idle periods

requestHandler: {

httpHandler: {

// Keep sockets alive for 45s — longer than EventBridge interval (300s)

// but shorter than Lambda's 10m max idle

connectionTimeout: 5000,

socketOptions: {

keepAlive: true,

keepAliveMsecs: 45000,

timeout: 5000,

},

},

},

});

export const handler = async (event, context) => {

try {

// 1. Make a real, lightweight call that exercises auth + signing + networking

await dynamo.send(new ListTablesCommand({ Limit: 1 }));

// 2. Validate socket pool is healthy

const sockets = dynamo.config.requestHandler.httpHandler.config.agent?.sockets;

const dynamoSockets = sockets?.['dynamodb.us-west-2.amazonaws.com:443'] || [];

if (dynamoSockets.length === 0) {

console.warn('⚠️ Warmer: no active DynamoDB sockets — pool may be empty');

// Force a second call to repopulate

await dynamo.send(new ListTablesCommand({ Limit: 1 }));

}

// 3. Wait for event loop to flush — do not set callbackWaitsForEmptyEventLoop=false

await new Promise(resolve => setTimeout(resolve, 100));

} catch (err) {

console.error('Warmer failed:', err);

throw err;

}

};

Key lessons:

  • Never use callbackWaitsForEmptyEventLoop = false in warmers — it defeats the purpose
  • Validate socket count after your call, not before — the pool may not be populated until the response fully arrives
  • Match runtime versions exactly — we had a warmer on nodejs18.x and handlers on nodejs20.x. They share zero state. Lambda reuses processes, not runtimes. We found this when our warmer logs showed process.versions.node: '18.17.0' but handler logs said '20.11.0' — and cold starts spiked the moment we upgraded handlers

Also: warmers don’t help if your handler opens new connections on every invocation. You must initialize clients globally — outside the handler — and reuse them.

This warmer cut cold starts from 35% → 0.8% — and kept it there for 14 months across 3 major SDK upgrades.

---

Cross-Account IAM Is a Landmine — And STS Propagation Lag Is Real

At GitHub, our monorepo deployed Lambdas to 12 a cloud provider accounts: prod-us-east-1, staging-us-west-2, audit-eu-west-1, etc. Each Lambda needed permissions to read from S3 buckets and write to CloudWatch Logs in its target account — but CI ran from a central ci-us-east-1 account.

We used Terraform + aws_iam_role with assume_role_policy pointing to the CI role:

resource "aws_iam_role" "lambda_role" {

name = "lambda-execution-${var.env}"

assume_role_policy = jsonencode({

Version = "2012-10-17"

Statement = [{

Effect = "Allow"

Principal = { AWS = "arn:aws:iam::123456789012:role/ci-role" }

Action = "sts:AssumeRole"

Condition = { StringEquals = { "sts:ExternalId" = var.external_id } }

}]

})

}

Every terraform apply failed with AccessDenied: Not authorized to perform sts:AssumeRole — but only sometimes. Sometimes it worked. Sometimes it failed on the aws_iam_role_policy_attachment step. Sometimes it passed apply but failed at terraform plan next time.

We spent three person-weeks debugging.

Turns out: STS trust policy propagation is eventually consistent — and the lag is real. According to a cloud provider Support (who finally admitted it after 11 tickets), “trust policy updates can take up to 60 seconds to propagate globally across STS endpoints.”

Our CI pipeline ran terraform applyterraform planterraform apply in rapid succession. So:

  • apply #1: creates role + trust policy
  • plan #2: tries to read role permissions → hits STS cache → gets stale “no trust policy” → fails
  • apply #3: retries → succeeds because now cache is updated

We proved it by adding a manual sleep 90 between role creation and any dependent resource — and it worked every time.

But hardcoding sleep in Terraform isn’t idiomatic. So we built a proper wait:

# Wait for STS trust policy to propagate

resource "null_resource" "wait_for_sts_propagation" {

triggers = {

role_arn = aws_iam_role.lambda_role.arn

}

provisioner "local-exec" {

interpreter = ["bash", "-c"]

command = <<-EOT

echo "⏳ Waiting 90s for STS trust policy propagation..."

sleep 90

echo "✅ STS propagation wait complete"

EOT

}

}

Now attach policies — after wait

resource "aws_iam_role_policy_attachment" "lambda_basic_exec" {

role = aws_iam_role.lambda_role.name

policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"

depends_on = [null_resource.wait_for_sts_propagation]

}

resource "aws_iam_role_policy_attachment" "s3_read" {

role = aws_iam_role.lambda_role.name

policy_arn = aws_iam_policy.s3_read.arn

depends_on = [null_resource.wait_for_sts_propagation]

}

But here’s the insider tip they won’t tell you in the docs: aws sts get-caller-identity is useless for validation.

It returns success immediately, even if the trust policy hasn’t propagated — because it validates the caller, not the assumed role’s permissions. So this check passes:

aws sts get-caller-identity --profile target-account

…even when aws lambda list-functions --profile target-account fails with AccessDenied.

The only reliable validation is to attempt an actual cross-account action with the target role — and do it after the wait.

So in our CI post-deploy step, we run:

# After null_resource.sleep completes

aws sts assume-role \

--role-arn "arn:aws:iam::987654321098:role/lambda-execution-prod" \

--role-session-name "ci-validation" \

--external-id "abc123" \

--duration-seconds 900 > /dev/null

THEN validate

aws lambda list-functions \

--region us-east-1 \

--profile target-account \

--max-items 1 > /dev/null

If that list-functions fails, we fail the whole pipeline.

This reduced CI failures from 30% → 0.2%. And saved us 3 person-weeks per quarter — because we now bake the wait into every cross-account deployment.

---

3 Common Pitfalls — With Line-by-Line Fixes

Pitfall #1: AWS_PROFILE in Environment Variables

What happens: You set AWS_PROFILE=prod in Lambda’s env vars thinking it’ll make the SDK use those creds. But AWS_PROFILE forces the SDK to load ~/.aws/credentials — which doesn’t exist in Lambda — so it falls back to all six credential providers, each doing sync I/O.

Symptom: Cold starts add 400ms+, CloudWatch Logs show repeated ENOENT: ~/.aws/credentials.

Fix — exact config:

// In your Lambda handler, before any SDK import

process.env.AWS_SDK_LOAD_CONFIG = '0'; // disables config file loading

process.env.AWS_SHARED_CREDENTIALS_FILE = '/dev/null'; // prevents fs attempts

process.env.AWS_PROFILE = ''; // clear it — don't rely on it

And never set AWS_PROFILE in Lambda console or Terraform environment.variables. If you need profile-like behavior, use fromIni({ profile: 'prod' }) explicitly, and only after verifying the file exists (it won’t in Lambda).

Pitfall #2: Using fromSSO() in Lambda

What happens: fromSSO() tries to open a browser, poll https://device.sso.us-east-1.amazonaws.com, and wait for user login — impossible in Lambda.

Symptom: Handler hangs for 60s, then fails with TimeoutError: Request failed due to a timeout.

Fix — pre-fetch tokens via Secrets Manager:

// Pre-generate SSO tokens locally with:

// aws sso login --profile my-sso-profile

// aws sso get-role-credentials --role-name MyRole --account-id 123456789012 --access-token $(cat ~/.aws/sso/cache/xyz.json | jq -r .accessToken) > /tmp/creds.json

// Then store /tmp/creds.json in Secrets Manager as JSON

// In Lambda:

import { fromTemporaryCredentials } from '@aws-sdk/credential-providers';

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const secrets = new SecretsManagerClient({ region: 'us-east-1' });

const ssoCreds = await secrets.send(new GetSecretValueCommand({

SecretId: 'sso-credentials-prod'

}));

const credentials = fromTemporaryCredentials({

params: JSON.parse(ssoCreds.SecretString),

// Use short-lived creds — rotate every 24h

masterCredentials: fromEnv({}), // IAM role to read secret

});

Pitfall #3: X-Ray Tracing + captureHTTPsGlobal

What happens: Enabling X-Ray active tracing and calling captureHTTPsGlobal() patches https module on every invocation — not once at startup. This breaks agentkeepalive, adds 12ms per call, and causes memory leaks.

Symptom: Gradual memory growth, AgentMaxSocketsError, ERR_HTTP_HEADERS_SENT errors.

Fix — global capture only:

// index.mjs — top level, outside handler

import { captureAWSv3Client } from 'aws-xray-sdk-core';

import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

const dynamo = new DynamoDBClient({ region: 'us-east-1' });

// ✅ Capture once, at module load

captureAWSv3Client(dynamo);

// ❌ NEVER do this inside handler:

// const xrayDynamo = captureAWSv3Client(new DynamoDBClient(...));

export const handler = async (event) => {

// Use same dynamo instance — no re-patching

await dynamo.send(new PutItemCommand(...));

};

And set this env var to prevent auto-patching:

AWS_XRAY_CONTEXT_MISSING=LOG_ERROR

Without it, X-Ray tries to auto-patch https on every handler invocation.

---

Insider Tips — Not in Any Documentation

Tip #1: --experimental-loader reveals real I/O bottlenecks

Add this to your Lambda’s NODE_OPTIONS:

NODE_OPTIONS="--experimental-loader ./loader.mjs"

With loader.mjs:

// loader.mjs

import { performance } from 'node:perf_hooks';

import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);

export function resolve(specifier, context, nextResolve) {

const start = performance.now();

return nextResolve(specifier, context).then((result) => {

const end = performance.now();

if (end - start > 10) {

console.warn(⏱️ Slow resolve: ${specifier} (${(end - start).toFixed(1)}ms));

}

return result;

});

}

We found util.promisify was taking 87ms on cold start — because it require()s internal/util every time it’s called at the top level. Fixed by moving it inside the handler.

Tip #2: process.dlopen() is your friend for native module bloat

Some packages (e.g., bcrypt, sharp) ship native binaries. They load via process.dlopen() — which isn’t logged by require() hooks. To catch them:

// At top of index.mjs

const originalDlopen = process.dlopen;

process.dlopen = function(target, filename, flags) {

console.time(dlopen ${filename});

const res = originalDlopen.apply(this, arguments);

console.timeEnd(dlopen ${filename});

return res;

};

We found sharp was loading 3x libvips variants — removed it and used jimp instead. Saved 112ms cold start.

Tip #3: --trace-warnings exposes silent SDK misconfigurations

Run locally with:

node --trace-warnings index.mjs

You’ll see warnings like:

(node:123) Warning: Setting the NODE_OPTIONS environment variable to --trace-warnings is not recommended

at new S3Client (/node_modules/@aws-sdk/client-s3/dist-cjs/...:123:45)

at Object.<anonymous> (/index.mjs:45:16)

More usefully: it catches AWS_SDK_JS_DANGER_DISABLE_ENDPOINT_DISCOVERY=1 being ignored (it’s deprecated), or retryMode: 'adaptive' failing silently because @aws-sdk/middleware-flexible-retry isn’t installed.

---

Tradeoffs: When to Use What

Approach A: ESM + Dynamic Imports

✅ Best for: New greenfield services, latency-sensitive workloads (payments, auth, APIs)

✅ Cold start wins: 42ms typical

❌ Tradeoff: Can’t use require.resolve() for plugin systems; harder to debug in local IDEs (some don’t support import() breakpoints)

💡 Use it if your p95 cold start > 200ms and you control the runtime (Node 18+)

Approach B: Container Images + Multi-Stage Build

✅ Best for: Teams already using containers, need glibc or Python/C++ deps

✅ Startup wins: ~120ms (faster than zip, slower than ESM)

❌ Tradeoff: 2.1GB image limit, slower CI (pushing layers), no native import() tree-shaking

💡 Use it if you need ffmpeg, pdftk, or other system binaries — but still apply ESM patterns inside the container

Approach C: Provisioned Concurrency + CJS

✅ Best for: Legacy apps you can’t refactor, compliance requirements (FIPS, HIPAA)

✅ Predictability: 0% cold starts if you size correctly

❌ Tradeoff: $12k+/month at 200 units, still pays for idle capacity, doesn’t fix per-invocation overhead

💡 Use it only if you’ve measured and proven ESM/dynamic imports aren’t possible — e.g., you depend on require.extensions hacks

---

What You Should Do Tomorrow

  • Add console.time('SDK init') to your handler — right before first SDK import. Deploy. Run one cold-start test. If it’s >50ms, you have work to do.
  • Switch to ESM this week — rename index.jsindex.mjs, add "type": "module" to package.json, update serverless.yml or samconfig.toml to point to index.mjs, and pin @aws-sdk/client-* to exact versions (3.472.0, not ^3.472.0).
  • Kill AWS_PROFILE in all Lambda environments — replace with explicit fromEnv({}) or fromInstanceMetadata({}). Run this in your CI before deploy:

   aws lambda update-function-configuration \

--function-name my-lambda \

--environment "Variables={}" \

--runtime nodejs18.x

  • Add the STS propagation wait — even if you’re not doing cross-account today. You will need it. Copy the null_resource block verbatim.
  • Log socket counts in your warmer — add the console.log('Socket pool status') snippet. If it ever shows active: 0, your warmer is lying.

None of this requires rewriting your business logic. None requires vendor lock-in. All of it is tested in production at a fintech startup I worked at, a streaming service, a ride-sharing company, and GitHub.

The 842ms cold start wasn’t “just how Lambda is.” It was a configuration debt — and configuration debt is the easiest tech debt to pay down.

Do it tomorrow. Your p95 will thank you.