Cold Start: Measure It for Real, Then Optimize What's Optimizable
The throttle alarm in the previous article reiterated two pain points that come with Lambda: concurrency limits and cold start. This article goes back to cold start seriously: measure it with real numbers on the exact resolve handler that now has Powertools wired in, then try each way to reduce it to see which is effective, which doesn't apply, and which is blocked by this very account.
Goal
Measure resolve's real cold start, then compare ways to reduce it: more memory, smaller package, SnapStart, and provisioned concurrency. Each has a trade-off, and on the test account one of them isn't usable — we'll see the real reason. The cost of the measurement is negligible.
Measuring cold start
To force a cold start, update the function configuration (which kills every warm environment), then call it immediately. The first call is cold, the rest are warm. The resolve handler is now much heavier than in Article 02: it loads Powertools, the SDK clients, and enables X-Ray. The numbers at the default 128 MB:
REPORT Duration: 1513.37 ms Billed Duration: 1817 ms Memory Size: 128 MB Max Memory Used: 96 MB Init Duration: 303.63 ms (cold)
REPORT Duration: 214.33 ms Billed Duration: 215 ms Memory Size: 128 MB Max Memory Used: 96 MB (warm)
A few things are worth reading carefully. Init Duration of 304 ms is the time to build the environment and run static code (create clients, load libraries). But the cold call's Duration reaches 1513 ms, seven times the 214 ms warm call, even though init only adds 304 ms. The rest of the gap is in the first run itself: at 128 MB the CPU is tiny, so the first execution (lazily loading the remaining modules, the first SDK call) crawls. And Max Memory Used of 96 MB is already close to the 128 MB ceiling; Powertools plus the SDK eat a fair amount of memory.
Option 1: more memory, because memory is CPU
Article 02 already showed that memory and CPU are one shared lever. Cold start is where that shows most clearly. Raise resolve from 128 to 512 MB and measure a cold start again:
REPORT Duration: 294.91 ms Billed Duration: 579 ms Memory Size: 512 MB Max Memory Used: 95 MB Init Duration: 283.42 ms
Init Duration is basically unchanged (283 vs 304 ms), but the cold call's Duration drops from 1513 ms to 295 ms, about five times faster. The reason is CPU: at 512 MB the function has four times the CPU of 128 MB, so the inherently CPU-heavy first run goes far faster. Max Memory Used is still around 95 MB, meaning the function isn't short on memory, it's short on CPU at startup. And Billed Duration drops from 1817 to 579 ms; as Article 02 showed, for CPU-heavy work more memory can be both faster and not much more expensive. This is the simplest and most effective way to reduce cold start here — just change one number.
Option 2: cut package size
The bigger the package, the longer it takes to load. The resolve bundle after esbuild minify is about 840 KB:
$ ls -la .aws-sam/build/ResolveLinkFunction/resolve-link.js
840262 bytes resolve-link.js
Most of that size is Powertools and the SDK clients bundled in. The ways to reduce it: import only the SDK clients you need (already done — we import each @aws-sdk/client-* separately rather than the whole block), don't bundle things the Lambda runtime already ships, and let esbuild minify (already on). For this code the gain from cutting more is small compared to raising memory, but for a very large package or many heavy dependencies it's worth doing.
Option 3: SnapStart, but not for Node
SnapStart snapshots the initialized environment image at publish-version time, then restores from that image instead of initializing again, cutting most of the cold start. But it doesn't apply here, and the reason is a detail you have to check in the docs rather than guess: SnapStart supports "Java 11+, Python 3.12+, and .NET 8+ runtimes". Our runtime is Node.js, which isn't on the list. If this series were written in Python or Java, SnapStart would be the top choice; with Node it's off the table, and you have to rely on the other options. This is exactly the kind of detail that's easy to get wrong from memory, because the list of supported runtimes expands over time.
Option 4: provisioned concurrency, blocked by the account
The surest way to eliminate cold start for Node is provisioned concurrency: keep a number of initialized environments always warm, so a request never has to wait on init. But it requires reserving a portion of concurrency, and on the test account that's blocked outright:
$ aws lambda put-function-concurrency --function-name "$RESOLVE" --reserved-concurrent-executions 5
An error occurred (InvalidParameterValueException): Specified ReservedConcurrentExecutions
for function decreases account's UnreservedConcurrentExecution below its minimum value of [10].
This account has a total concurrency limit of 10 (seen in Article 06), and AWS won't allow reserving any portion if doing so pulls the unreserved part below the minimum of 10. In other words, to use provisioned concurrency you have to request a concurrency quota increase via Service Quotas first. This is a real constraint of the environment, not a config error, and it shows why "just enable provisioned concurrency" sometimes can't be done right away.
Practical considerations
Pulling it together, the priority order for cold start of a Node function on an account like this is: tune the memory right (the biggest win, nearly free in effort), keep static code lean and create clients once at the module level (done since Article 06), cut the bundle if it bloats. Provisioned concurrency is for when you've already requested a quota increase and genuinely need stable latency on every request, while SnapStart is worth considering if the language is Java, Python, or .NET. For a URL shortener where cold start only touches the few requests that land while a new environment is being built, raising the memory to a reasonable level is usually enough.
🧹 Cleanup
Resolve's memory has been returned to 128 MB to match the template; the reserved-concurrency command failed so it left nothing behind:
aws lambda update-function-configuration --function-name "$RESOLVE" --memory-size 128
Keep the stack for the next article.
Wrap-up
Cold start is measurable, and each way to reduce it has its place. Raising memory cut resolve's cold start by about five times because it increased CPU at startup, and it's the simplest option on this account. Cutting the bundle helps when the package is large. SnapStart is powerful but not for Node. Provisioned concurrency is the surest option but is blocked by the account's concurrency limit until the quota is raised. The general lesson is to measure first, then pick the option that fits the real constraints, rather than applying a formula.
The rest of operations is security. The next article tightens permissions: shrink each function's IAM down to exactly what it needs (least-privilege), move secrets to a properly managed place, set throttling to fight abuse, and discuss how to attach WAF to an HTTP API, which has to go around through CloudFront.