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.jsonto findmain - 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()chain —import()defers resolution until first use - Tree-shaking at build time — we ran
esbuild --tree-shaking=true --platform=node --target=node18and manually pruned unused commands (DeleteObjectCommand,ListBucketsCommand, etc.) from the final bundle - Zero SDK initialization on cold start —
getS3Client()runs inside the handler, so itsimport()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_WAITstate
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 = falsein 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.xand handlers onnodejs20.x. They share zero state. Lambda reuses processes, not runtimes. We found this when our warmer logs showedprocess.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 apply → terraform plan → terraform apply in rapid succession. So:
apply #1: creates role + trust policyplan #2: tries to read role permissions → hits STS cache → gets stale “no trust policy” → failsapply #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.js→index.mjs, add"type": "module"topackage.json, updateserverless.ymlorsamconfig.tomlto point toindex.mjs, and pin@aws-sdk/client-*to exact versions (3.472.0, not^3.472.0). - Kill
AWS_PROFILEin all Lambda environments — replace with explicitfromEnv({})orfromInstanceMetadata({}). 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_resourceblock verbatim. - Log socket counts in your warmer — add the
console.log('Socket pool status')snippet. If it ever showsactive: 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.