Skip to content

Lambda Cold Starts Follow Idle Gaps, Not Bundle Size

Logs from a 289-to-44 Lambda consolidation: cold start rate tracks the idle gap per function, bundle size adds about 150 ms per MB here, and the queries to measure both.

Ayhan Sipahi Ayhan Sipahi

A Lambda function’s cold start rate is set by how long it sits idle between its own invocations. Bundle size sets the price of each cold start and leaves the rate alone; in the data below that price is about 150 ms per unzipped MB above a 1.2 s floor. The standard advice points the other way: keep functions small so they start fast. It says nothing about traffic, which was the variable that decided everything here.

A fleet-management platform folded 289 single-purpose Node.js Lambdas into 44 functions: one Hono router per domain, with public endpoints kept separate. Before the move, 168 of its 246 functions with log groups had zero invocations in a week. Functions under one request per day were cold on 78% of the invocations they did get. The consolidation was forced by the HTTP API quota of 300 integrations per API. The logs on either side of it therefore give a before-and-after comparison on the same system, with matched weekday windows, on a question usually argued from folklore.

A TypeScript team with tens to hundreds of HTTP endpoints on Lambda needs four things to decide whether to consolidate: the measured idle-gap curve, the per-function traffic curve, the cost of bundle size in milliseconds, and the two Logs Insights queries and one script that produce the same numbers in any account. All four follow. Whether to consolidate at all is settled in AWS Lambda: Single-Purpose Functions vs Lambdalith; the numbers here put a value on the first of its exception criteria.

The setup behind every number below: production, eu-north-1, Node.js 24 on arm64, 1024 MB, esbuild ESM bundles minified for production with @aws-sdk/* left external because the runtime provides it. The before dataset is seven days of REPORT lines per single-purpose function, read from the archived log groups. The after dataset is 1.0 to 1.6 days of REPORT lines from the seven domain routers that carried traffic. Matched-window comparisons use the same weekday hours one week earlier. The migration followed the route-by-route sequence in Single-Purpose Lambdas to a Hono Lambdalith, Route by Route, with one difference in the router: instead of extracting each handler’s core into a shared function, Hono picks the endpoint from the domain’s route table and calls the existing handler with the original API Gateway event. Handler code did not change. Public, unauthenticated endpoints kept their own functions so they never run under a router’s IAM role.

What the Idle-Gap Curve Shows#

For every invocation, take the minutes since the previous invocation of the same function, put it in a bucket, and count the share of invocations in that bucket that carried an Init Duration. This is the function’s gap, whichever environment served the previous request; the environment’s own idle time is at least that long, and the second table below measures it directly. The before column pools the 78 single-purpose functions that had traffic; the after column pools the seven routers.

Gap since previous invocationInvocations (before)Cold (before)Invocations (after)Cold (after)
0 to 1 min21,5462.2%6,3722.4%
1 to 3 min2,1152.5%3891.8%
3 to 5 min2654.9%250.0%
5 to 7 min11944.5%1631.3%
7 to 10 min14783.7%1650.0%
10 to 15 min10698.1%1384.6%
15 to 30 min191100%18100%
30+ min707100%49100%

The execution environment survived short gaps and died somewhere between 5 and 10 idle minutes. By 15 minutes it was always gone. The after column has the same shape, but its 5 to 15 minute rows hold 13 to 16 samples each, so it confirms the shape without adding precision.

The function gap is a lower bound on how long the environment that served the previous request had been idle: under concurrency, that request may have gone to another environment. Re-reading the same before rows by log stream, so that each warm invocation is bucketed by its own environment’s idle time, gives the second column below. The third column is the direct survival count: warm invocations that reused an environment after a gap that long.

GapCold, by function gapCold, by environment gapEnvironments reused after a gap this long
5 to 7 min44.5%30.8%119
7 to 10 min83.7%69.1%55
10 to 15 min98.1%95.4%5
15 to 30 min100%100%0
30+ min100%100%0

The environment view lowers the 5 to 10 minute rows by about 14 points and leaves the edges where they were. Of 23,483 warm invocations, the longest idle gap any environment was reused after was 10.3 minutes; five reuses came after more than 10 minutes and none after 15. Most single-purpose functions never had two environments active within the same five minutes (54 of the 78 with traffic). The routers ran two to six at once, and re-reading their logs the same way gives 18% at 5 to 7 minutes, 42% at 7 to 10, 78% at 10 to 15, a longest reused gap of 10.9 minutes and nothing past 15.

The 2% floor under 5 minutes comes from a different mechanism than idleness. It is a second concurrent request arriving while the only warm environment is busy, which forces a new environment, plus the proactive initialization AWS documents: “Lambda might occasionally initialize execution environments ahead of invocation requests”. Consolidation cannot remove this floor. The notification router, at 3,484 requests per day, sat at 0.5%.

AWS’s own wording is that Lambda “maintains the execution environment for some time in anticipation of another function invocation”. The curve above is the number behind that phrase for one account, one region and one period. It is a measurement to repeat. AWS also states that “Lambda terminates execution environments every few hours to allow for runtime updates and maintenance” regardless of traffic, and nothing in the documentation promises the 5 to 10 minute window.

Traffic per Function Sets the Rate#

Invocations divided by cold starts gives requests served per cold start, the most intuitive number here. Before the migration, one cold start on the quietest functions paid for 1.3 requests. On the five routers that pooled traffic, one cold start paid for 8 to 26; the two whose domain one endpoint already carried stayed at 7 and 200. The table covers the 246 single-purpose functions with log groups; 168 of them had zero invocations in the week and sit outside it.

Requests per day per functionFunctionsInvocationsColdRequests served per cold start
under 1268378.3%1.3
1 to 31519663.3%1.6
3 to 101860445.9%2.2
10 to 3091,19632.4%3.1
30 to 10073,18115.5%6.5
100 to 30011,1241.6%62
over 300218,8902.2%45

The routers after migration sit on the same curve: 118 requests per day gave 14.5% cold, 184 gave 11.7%, 238 gave 12.1%, 297 gave 6.6%, 787 gave 6.8%, 964 gave 3.9%, and 3,484 gave 0.5%.

The 100 to 300 row beats the over-300 row, and the reason reinforces the point. That one function is a job-status endpoint that clients poll in tight bursts while a job runs and not at all between jobs: its gaps were under a minute at p90 and up to 41 hours at the extreme, so each job paid one cold start and about 60 polls rode warm behind it. Requests per cold start, not requests per day, is what that row measures. Regularity of arrivals matters as much as their count.

Seen through this table, consolidation is a traffic-pooling move. Seven routers at 118 to 3,484 requests per day replaced 78 functions at 0.1 to 2,100. Nothing about the code became warmer; the gaps between invocations of the same function became shorter.

What Changed in Matched Windows#

Same weekday hours one week earlier against the after window, all seven domains pooled:

MetricBefore (matched)After
Invocations5,5496,905
Cold start rate7.9%3.8%
Cold start cost per request (rate × init p50)94 ms53 ms
Duration p5071 ms72 ms
Duration p90488 ms250 ms
Duration p993,264 ms3,283 ms

p50 is flat and p90 halves. The table measures Duration, the handler’s own time; a cold request adds its Init Duration on top. The p90 move is arithmetic on the cold share: before, cold starts were 7.9% of requests, so the top decile was almost all cold and p90 sat just under it; after, at 3.8%, the top decile is mostly warm requests. The median never meets a cold start in either window. A change in request mix can move p90 too (orders carried 48% more traffic after), so read the halving as consistent with the rate drop, not as proof of it.

Per domain, cold rate and cold cost per request moved like this:

DomainCold beforeCold afterCost per request beforeCost per request after
fleet12.7%6.6%154 ms80 ms
driver9.4%6.8%110 ms87 ms
auth17.0%12.1%200 ms153 ms
orders20.4%3.9%243 ms70 ms
notification0.8%0.5%10 ms6 ms
tracking11.4%14.5%132 ms181 ms

Cost per request is each window’s cold rate times that window’s init p50. Orders also moved at p90, from 1,123 ms to 510 ms. Customers has no matched-window baseline because the slot a week earlier carried zero requests; its unmatched figures, seven days before against the shorter after window, went from 41.0% to 11.7%.

Two domains did not move. Tracking’s sixteen functions had one function carrying all of the domain’s traffic, and notification’s two hot functions were already at the concurrency floor. Where one endpoint already carries the domain, there is nothing to pool. The 10 versus 25 cold starts behind tracking’s percentages are too few to call a change in either direction.

The orders p50 shift, 109 ms to 166 ms in matched windows, stays unexplained. The other six domains’ p50 did not move, which argues against a per-request router tax; the after window carried 48% more orders traffic, which argues for a change in the endpoint mix. The REPORT line has no endpoint, so the data cannot settle it; the access-log fix in the measurement section would have.

Two side effects came with the consolidation. Total zipped code fell from 89.5 MB to 16.2 MB and functions packaged per deploy from 308 to 63, because each single-purpose function had bundled its own copy of the shared code and middleware. Typical deploy time fell from 18.4 to 10.3 minutes in dev and from 15.0 to 8.6 minutes in prod, read from CI job durations.

What Bundle Size Costs, in Milliseconds#

Init p50 against unzipped bundle size, for functions with at least five cold starts:

Bundle, unzippedFunctionsCold startsInit p50Init p90
1.0 to 1.5 MB, single-purpose421,6151,209 ms1,289 ms
1.5 to 2 MB, single-purpose8931,282 ms1,402 ms
2 to 3 MB, single-purpose2241,275 ms1,330 ms
1.6 to 2.3 MB, five routers51921,246 to 1,333 ms1,312 to 1,425 ms
5.5 MB, customers router1301,835 ms1,930 ms
5.8 MB, orders router1391,909 ms2,020 ms

Two things are visible. There is a floor near 1.2 s that bytes do not explain: a 1.0 MB single-purpose function and a 2.3 MB router both land within about 0.1 s of it. Above the floor there is a slope of roughly 150 ms per unzipped MB, which is what takes the two heavy routers to 1.8 and 1.9 s. Two routers set that slope, so it is this fleet’s correlation between bytes and init work, not a constant; module count and what runs at import time travel with the bytes and were not separated here. The floor is init-phase work: the runtime, SDK clients, and whatever else runs at module scope. What that work is made of and how to amortize it is the subject of Code Architecture by Init Amortization; the table above only measures its size.

The two heavy routers carry report generation (PDF, Excel) and payment code that only a few of their endpoints use. Because the price is per MB, the fix is to keep those modules out of the static import graph and load them on first use. By the slope, that should bring the router’s init back toward the 1.3 s band of the other five; it is a prediction, not a measurement from this dataset:

// reports.ts: load the PDF stack on first use, not at init
let pdf: typeof import('./pdf.js') | undefined

export async function renderPdf(input: ReportInput) {
  pdf ??= await import('./pdf.js')
  return pdf.render(input)
}

The gain comes from deferring module evaluation, not from shrinking the file. Without code splitting, esbuild keeps a dynamically imported module inside the same bundle and wraps it to run on first use, so the unzipped MB stays and the init work moves.

The first request to the heavy endpoint pays the import once, and every later cold start skips it. Any module-scope singleton for that module has to be created after the import completes.

Memory followed the same shape and gives no reason to resize: p50 Max Memory Used was 202 MB for single-purpose functions and about 330 MB for the two heavy routers, all far below the 1024 MB configured.

Where the Single-Purpose Case Still Holds#

The structural arguments in the granularity post are untouched by this data, and two of them shaped this migration.

IAM scope. A router runs with the union of its endpoints’ permissions. That is why the public endpoints stayed as their own functions: they never run under a router’s role.

Per-endpoint observability. REPORT lines lost the endpoint, and the unexplained orders p50 above is that cost made visible. The measurement section gives the two ways back, a route dimension in metrics or API Gateway access logs.

Blast radius and deploy coupling. One bad deploy now takes a domain down instead of one endpoint. Versions and aliases reduce the exposure; they do not remove it.

Reserved or provisioned concurrency per route. Neither is available once routes share a function. None of these domains needed it, but a domain with one bursty route should keep that route separate, which is the hybrid the granularity post describes.

The knobs single-purpose buys, memory and reserved concurrency per endpoint, were never turned in this fleet: every function ran at 1024 MB and no domain reserved concurrency, so the granularity was paid for options nobody exercised.

The measured claim sits inside those constraints. When per-endpoint traffic is thin and the IAM scope is shared, pooling wins on cold starts, package size and deploy time. When one endpoint already carries the domain, pooling buys nothing on any of those.

What Changes at Ten and a Thousand Times the Traffic#

The curve answers the scale question without new data. Because the cold rate is a per-function traffic property, multiply every function’s traffic by k, read each one off the measured requests-per-day curve above, and weight by requests. The measured fleet sits at about 0.1M requests per month.

Requests per monthkSingle-purpose fleet, coldRouters, coldRequests from endpoints under 30/day
0.1M (measured)17.1%2.1% predicted, 3.8% observed8.2%
1M102.9%2.2%1.1%
10M1002.3%2.2%0.1%
100M1,0002.2%2.2%0.0%

The pooling gain is mostly gone by about 1M requests per month and gone by 10M. Under uniform growth the single-purpose fleet reaches the floor the routers already sit on. What remains at the floor (about 2% here) is scale-out and proactive inits, which no topology removes.

At the floor the sign flips. Every scale-out cold start pays the router’s bundle: 1.9 s on the heavy routers against 1.2 s for a lean single-purpose function. The idle-gap argument for pooling is finished at that point; the per-MB argument against it is still running. Lazy imports come first, carving the heavy endpoints out of the router second.

Pooled traffic is smoother than its parts, so a router should need fewer scale-out environments per request than the sum of its endpoints would. That is theory; this dataset is too small to show it.

Money, briefly: INIT duration has been billed for on-demand managed-runtime functions since August 2025. At 100M requests per month, 2.2% cold and 0.7 s of extra init at 1 GB, the difference is tens of dollars a month. Bundle size is a latency lever long before it is a cost lever.

The costs that grow with traffic are the ones from the previous section. One memory size serves a PDF render and a 70 ms GET. One concurrency pool means one endpoint’s burst throttles its siblings. One deploy recycles every environment of the domain at once. At high concurrency that is a domain-wide latency event, where the single-purpose layout confines it to one endpoint.

Two caveats belong next to the table. Uniform growth is an assumption, and real growth lands on the hot endpoints first, which keeps the tail cold for longer. Nothing above 300 requests per day per function was measured, so the 2.2% in the bottom rows is a measured point re-read under an assumption. The floor is set by burstiness; volume alone does not move it. AWS’s own typical figure is lower still: “Cold starts typically occur in under 1% of invocations.”

Splitting the Gateway Solves a Different Problem#

The field’s default answer to the quota is to split the API, and it deserves a direct comparison. The HTTP API quotas are: routes per API 300 (adjustable); integrations per API 300 (fixed); multi-level API mappings per custom domain 200 (fixed); custom domain names per Region 120 (adjustable). One single-purpose Lambda is one integration, so a route increase does not let one HTTP API hold more than 300 single-purpose functions.

This account had the route increase and still migrated. Routes per HTTP API went to 400 through Service Quotas before the first wave, as deploy headroom. CloudFormation creates the new proxy routes before it deletes the explicit ones, and an API at 293 routes cannot swap in place under 300. The increase bought the deploy sequence. The prod API stood at 287 integrations, and the integration quota does not appear in Service Quotas at all. After the migration the same API has 137 routes and 44 integrations, and 40 of the routes are {proxy+}.

Past 300 integrations there are two exits. One API per domain behind one custom domain with API mappings (api.example.com/orders to the orders API) keeps every function. Fewer integrations through routers keeps one API. The migration took the second; the first is the real alternative.

Splitting keeps everything single-purpose buys at the gateway: per-route JWT authorizer scopes, per-route throttling, per-API stages, access logs and quotas per domain. Under a {proxy+} route those move into the router’s code, and scopes in particular collapse to one set per domain unless the router checks token claims itself.

Splitting does not touch the tail. The 168 zero-traffic functions and the 78% cold rate under one request per day are per-function properties; another gateway in front changes neither.

Splitting costs one stack and one API mapping per domain; single-level mappings such as /orders do not count against the 200 multi-level cap. The account-level throttle is shared across every API in the Region, so splitting is no throughput lever. Doing both, one API per domain and one router per domain, is the maximum-isolation layout; it is worth the extra stacks only when domains need different authorizers or throttles.

The two decisions reduce to a short table:

SituationSplit or group
Quota only, endpoints warmEither; equal on the quota
Cold tail, shared IAM scopeGroup
Per-route gateway controls in useSplit
Quota and cold tail togetherGroup, keep privileged routes separate

The quota set the timing of this migration; the traffic curve set the answer.

Measure Your Own Curve#

Everything above comes from the REPORT line, which is free and needs no agent. AWS’s documented list of discovered Lambda fields has no init field (@requestId, @duration, @billedDuration, @type, @maxMemoryUsed and @memorySize). An undocumented @initDuration does resolve, and on one router count(@initDuration) matched the parse below exactly; the queries here still parse @message, which depends on nothing undocumented. That is where Init Duration lives, defined by AWS as the time “it took the runtime to load the function and run code outside of the handler method”. Each execution environment writes to its own log stream (“A separate log stream is created in the log group for each Lambda function instance”), so @logStream identifies the environment.

Query 1 gives cold rate, init and environment count per function. Select the function log groups, or a prefix, in the console.

filter @type = "REPORT"
| parse @message /Init Duration: (?<init>[\d.]+) ms/
| stats count(*) as invocations,
        count(init) as coldStarts,
        count_distinct(@logStream) as environments,
        pct(init, 50) as initP50,
        pct(@duration, 50) as p50,
        pct(@duration, 90) as p90
  by @log
| sort invocations desc

invocations / coldStarts is requests served per cold start. environments equal to coldStarts is the check that every new stream is exactly one init; count_distinct is exact at this cardinality and approximate only at very high cardinality, and the check held on every function in this dataset. On one production router over three days the query returned 1,471 invocations, 80 cold starts, 80 distinct streams, init p50 1,878 ms, p50 142 ms and p90 586 ms.

Query 2 shows how long each environment lived and how much it served.

filter @type = "REPORT"
| stats count(*) as invocations,
        min(@timestamp) as firstSeen,
        max(@timestamp) as lastSeen
  by @logStream
| sort invocations desc
| limit 50

On the same router the busiest environment served 99 invocations over 67 minutes, and four environments were created in the same second during a burst, which is the 2% floor in one row. Logs Insights returns up to 10,000 rows by default and up to 100,000 with limit. The two queries aggregate every matching event, so the cap matters only when exporting raw REPORT rows, which the script below does through the CLI instead.

The gap curve needs a script. Logs Insights has autoregress for previous-row values, but its documented syntax has no per-function partition across log groups, and the bucketed histogram is one loop locally. The script prints both gap curves, function gap and environment gap from the stream name, and the longest idle gap an environment was reused after. Both the queries and the script assume the default text log format; with the JSON log format the report is a JSON object with different field names, so @type = "REPORT", the regex and the CLI filter pattern match nothing and report nothing. The script pulls REPORT events per log group through the AWS CLI (the CLI handles pagination, no npm dependencies), then prints per-function rate, environments and requests per cold start, and the pooled gap curve. Its analyze() reproduced every table above from the archived dataset.

#!/usr/bin/env node
// cold-gaps.cjs: cold-start rate, invocations per environment, the idle-gap curve two ways
// (per function and per execution environment) and environment survival, computed from
// Lambda REPORT lines. No npm dependencies; needs the AWS CLI on PATH with read access
// to the log groups.
//
// usage: node cold-gaps.cjs --region eu-north-1 --days 7 /aws/lambda/fn-a /aws/lambda/fn-b
const { execFileSync } = require('node:child_process')

const MIN = 60_000
const GAP_BUCKETS = [[0, 1], [1, 3], [3, 5], [5, 7], [7, 10], [10, 15], [15, 30], [30, Infinity]]

// One row per invocation: timestamp, Init Duration (null when warm), execution environment.
function collect(logGroup, region, fromMs, toMs) {
  const out = execFileSync(
    'aws',
    ['logs', 'filter-log-events', '--region', region, '--log-group-name', logGroup,
      '--start-time', String(fromMs), '--end-time', String(toMs),
      '--filter-pattern', '"REPORT RequestId"', '--output', 'json'],
    { maxBuffer: 1 << 29 },
  )
  return JSON.parse(out).events
    .map((e) => {
      const init = e.message.match(/Init Duration: ([\d.]+) ms/)
      return { t: e.timestamp, init: init ? Number(init[1]) : null, env: e.logStreamName }
    })
    .sort((a, b) => a.t - b.t)
}

function analyze(rowsByFn) {
  const perFn = Object.entries(rowsByFn).map(([fn, rows]) => {
    const cold = rows.filter((r) => r.init !== null)
    const inits = cold.map((r) => r.init).sort((a, b) => a - b)
    return {
      fn,
      invocations: rows.length,
      coldStarts: cold.length,
      coldPct: rows.length ? (100 * cold.length) / rows.length : null,
      environments: new Set(rows.map((r) => r.env)).size,
      invPerColdStart: cold.length ? rows.length / cold.length : null,
      initP50: inits[Math.floor(inits.length / 2)] ?? null,
    }
  })
  // Gap curve two ways, pooled over all functions. Function gap: minutes since the SAME
  // function's previous invocation, whichever environment served it. Environment gap:
  // minutes since the same environment's (log stream's) previous invocation. A cold start
  // has no environment history, so it counts at the function gap in both curves.
  const gaps = GAP_BUCKETS.map(([lo, hi]) => ({ gapMin: `${lo}-${hi === Infinity ? '' : hi}`, n: 0, cold: 0, envN: 0, envCold: 0, envReused: 0 }))
  const bucket = (g) => gaps[GAP_BUCKETS.findIndex(([lo, hi]) => g >= lo && g < hi)]
  const survival = { longestIdleMinReused: 0, reusesAfter10Min: 0 }
  for (const rows of Object.values(rowsByFn)) {
    const lastByEnv = new Map()
    for (let i = 0; i < rows.length; i++) {
      const r = rows[i]
      if (i > 0) {
        const fnGap = (r.t - rows[i - 1].t) / MIN
        const envGap = r.init === null && lastByEnv.has(r.env) ? (r.t - lastByEnv.get(r.env)) / MIN : fnGap
        const bf = bucket(fnGap), be = bucket(envGap)
        bf.n++
        be.envN++
        if (r.init !== null) {
          bf.cold++
          be.envCold++
        } else {
          be.envReused++
          if (envGap > survival.longestIdleMinReused) survival.longestIdleMinReused = envGap
          if (envGap >= 10) survival.reusesAfter10Min++
        }
      }
      lastByEnv.set(r.env, r.t)
    }
  }
  return {
    perFn,
    gaps: gaps.map((g) => ({ ...g, coldPct: g.n ? (100 * g.cold) / g.n : null, envColdPct: g.envN ? (100 * g.envCold) / g.envN : null })),
    survival,
  }
}

if (require.main === module) {
  const args = process.argv.slice(2)
  const opt = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args.splice(i, 2)[1] : d }
  const region = opt('--region', process.env.AWS_REGION ?? 'eu-north-1')
  const days = Number(opt('--days', 7))
  const to = Date.now(), from = to - days * 86_400_000
  const rowsByFn = Object.fromEntries(args.map((g) => [g.replace('/aws/lambda/', ''), collect(g, region, from, to)]))
  const { perFn, gaps, survival } = analyze(rowsByFn)
  const r1 = (v) => (v === null ? '-' : v.toFixed(1))
  console.table(perFn.map((r) => ({ ...r, coldPct: r1(r.coldPct), invPerColdStart: r1(r.invPerColdStart) })))
  console.table(gaps.map((g) => ({ gapMin: g.gapMin, n: g.n, coldPct: r1(g.coldPct), envN: g.envN, envColdPct: r1(g.envColdPct), envReused: g.envReused })))
  console.log(`longest idle gap an environment was reused after: ${survival.longestIdleMinReused.toFixed(1)} min; reuses after 10+ min: ${survival.reusesAfter10Min}`)
}

module.exports = { analyze, collect }

If a dashboard number matters more than the curve, Powertools for AWS Lambda (TypeScript) emits a ColdStart metric through EMF with one line of middleware. Lambda Insights adds an init_duration metric as a paid extension that brings its own init. Neither gives the gap curve; both give a number an alarm can watch. In the snippet, routerHandler is the router’s Lambda handler, handle(app) in Hono. Powertools does not emit ColdStart as 0 on warm invocations, so a rate needs metric math against Invocations.

import { Metrics } from '@aws-lambda-powertools/metrics'
import { logMetrics } from '@aws-lambda-powertools/metrics/middleware'
import middy from '@middy/core'

const metrics = new Metrics({ namespace: 'fleet', serviceName: 'orders' })

export const handler = middy(routerHandler).use(logMetrics(metrics, { captureColdStartMetric: true }))

Getting the endpoint back after consolidation takes one of two moves. Inside the router, add the matched route as a metric dimension or a structured-log field, which is what the granularity post’s reconstructable-observability criterion asks for. Outside it, enable HTTP API access logs with $context.path and $context.integrationLatency, which give per-path latency behind {proxy+} without touching code. $context.routeKey shows the proxy route, so $context.path is the field that carries the endpoint; it is the concrete path with IDs in it, so group on a templated form, and add $context.httpMethod to tell methods apart. Enable the access logs before the migration so both windows have them.

Ways to Misread the Curve#

Comparing a 7-day window to a 1-day window. The raw report had the orders p50 at 70 ms before and 166 ms after; matching weekday hours moved the before figure to 109 ms. Always compare the same weekday hours.

Quoting a percentage from a two-digit count. Tracking’s 14.5% after is 25 cold starts against 10 before. Say no change and show the counts.

Blaming a deploy for a domain’s cold starts without checking timestamps. The full production deploy inside the after window cost exactly one cold start per router, 7 of 6,905 invocations. Tracking’s rate had nothing to do with it; the timestamps settled that in a minute.

Counting cold starts after invoke failures. A suppressed init after a crash or timeout re-runs the Init phase without reporting it; the extra time lands in Duration, not in Init Duration, so an error-heavy function undercounts its cold starts. The Telemetry API reports these as init events with the phase set to invoke; the REPORT line does not.

Decision Rules by Requests per Cold Start#

The granularity post’s default stands: single-purpose functions, with a single-domain router as the earned exception. The first exception criterion now has a number behind it: requests served per cold start, per function, from Query 1.

Requests per cold startContextDecision
above about 20anyLeave the function alone; it is within a few points of the floor
2 to 20shared IAM scope, one deploy cadencePool into a domain router; expect the rate to fall toward the router’s traffic level
below 2isolated IAM needs or reserved concurrencyKeep the function and accept the rate, or add provisioned concurrency if it is latency-critical (tactics)
anyone endpoint already carries the domainPooling buys nothing on cold starts; decide on IAM and deploy grounds
anyrouter init above about 1.5 sLazy-import the heavy modules first; split the domain second
anyabove roughly 1M requests per month with the same skewPooling barely moves the rate; decide on quota, IAM, deploy and bundle grounds

The default holds where one endpoint already carries its domain, or where every endpoint sees a few hundred requests per day; there pooling changed nothing, and IAM scope, deploy coupling and bundle size decide alone. The override is the thin tail: endpoints under roughly 30 requests per day that share an IAM scope and a deploy cadence, where one router per domain halved the cold rate and the per-request cold cost and left the pooled p50 untouched. The next step for any account is Query 1 over the last seven days, sorted by requests per cold start.

References#

Related posts