Lambda Layer Strategies for Shared Dependencies at Scale
Practical patterns for managing Lambda Layers — when to share, when to bundle, versioning strategies, and the cold start trade-offs most teams ignore.

The Dependency Management Problem
As your Lambda fleet grows past 20-30 functions, dependency management becomes a real operational burden. Every function bundles its own copy of the AWS SDK, your shared utilities, Powertools, and whatever ORM or HTTP client you've standardized on. The result: 50 deployment packages that each contain 80% identical code, slow deployments, and version drift where Function A uses SDK v3.450 while Function B is stuck on v3.398.
Lambda Layers promise to solve this: shared dependency packages that multiple functions reference. But the reality is more nuanced than "put everything in a layer." After managing layers across a fleet of 120+ functions, here are the patterns that actually work — and the ones that don't.
Architecture: Layer Types and Use Cases
We categorize layers into three types based on update frequency and scope:
- Runtime Layer — AWS SDK, Powertools, shared utilities. Updated monthly.
- Business Logic Layer — Domain-specific shared libraries (validation schemas, API clients). Updated per sprint.
- Binary Layer — Native binaries (sharp, ffmpeg, Prisma engines). Updated quarterly.
// CDK: Layer definitions with versioning
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as path from 'path';
// Runtime Layer — shared across ALL functions
const runtimeLayer = new lambda.LayerVersion(this, 'RuntimeLayer', {
code: lambda.Code.fromAsset(path.join(__dirname, '../layers/runtime'), {
bundling: {
image: lambda.Runtime.NODEJS_20_X.bundlingImage,
command: [
'bash', '-c',
'npm ci --production && cp -r node_modules /asset-output/nodejs/node_modules',
],
},
}),
compatibleRuntimes: [lambda.Runtime.NODEJS_20_X],
description: `Runtime deps v${runtimeVersion} — SDK, Powertools, zod`,
});
// Business Logic Layer — shared across domain functions
const domainLayer = new lambda.LayerVersion(this, 'DomainLayer', {
code: lambda.Code.fromAsset(path.join(__dirname, '../layers/domain')),
compatibleRuntimes: [lambda.Runtime.NODEJS_20_X],
description: `Domain libs v${domainVersion} — validators, API clients`,
});
// Apply layers to functions
const processOrder = new lambda.Function(this, 'ProcessOrder', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('../dist/process-order'),
layers: [runtimeLayer, domainLayer],
memorySize: 512,
});
The Cold Start Trade-Off
Here's what most articles about Lambda Layers don't tell you: layers can increase cold start time. When Lambda initializes a new execution environment, it downloads and extracts all layers before your function code. Larger layers = longer cold starts.
We benchmarked cold start impact across different layer configurations:
// Cold start benchmark results (Node.js 20, 512MB memory)
interface LayerBenchmark {
config: string;
layerSizeMb: number;
coldStartMs: number;
warmInvokeMs: number;
}
const benchmarks: LayerBenchmark[] = [
{ config: 'No layers (bundled)', layerSizeMb: 0, coldStartMs: 210, warmInvokeMs: 3 },
{ config: '1 layer (5MB runtime)', layerSizeMb: 5, coldStartMs: 240, warmInvokeMs: 3 },
{ config: '1 layer (30MB runtime)', layerSizeMb: 30, coldStartMs: 380, warmInvokeMs: 3 },
{ config: '2 layers (30MB + 10MB)', layerSizeMb: 40, coldStartMs: 450, warmInvokeMs: 3 },
{ config: '3 layers (30MB + 10MB + 50MB binary)', layerSizeMb: 90, coldStartMs: 890, warmInvokeMs: 3 },
{ config: 'Bundled equivalent (90MB zip)', layerSizeMb: 0, coldStartMs: 920, warmInvokeMs: 3 },
];
Key insight: layers don't add cold start overhead versus bundling the same dependencies. The download time is roughly equivalent whether deps are in layers or in the function package. But layers let you cache the dependency download across function updates — Lambda caches layers separately from function code.
This means: updating your function code (business logic) doesn't re-download the layer. The layer is already cached in the execution environment pool. This dramatically improves deployment speed for iterative development.
Layer Versioning Strategy
Lambda Layers are immutable — each publish creates a new version. The question is: how do you manage version references across 120+ functions?
Approach 1: SSM Parameter (Our Preferred Method)
// Publish layer and store version in SSM
import { SSMClient, PutParameterCommand } from '@aws-sdk/client-ssm';
async function publishLayer(layerName: string, version: number) {
const ssm = new SSMClient({});
await ssm.send(new PutParameterCommand({
Name: `/layers/${layerName}/latest-version`,
Value: version.toString(),
Type: 'String',
Overwrite: true,
}));
}
// CDK: Reference layer by SSM parameter
const runtimeLayerVersion = ssm.StringParameter.valueFromLookup(
this, '/layers/runtime/latest-version'
);
const runtimeLayer = lambda.LayerVersion.fromLayerVersionArn(
this, 'RuntimeLayer',
`arn:aws:lambda:${this.region}:${this.account}:layer:runtime:${runtimeLayerVersion}`
);
When the platform team publishes a new layer version, they update the SSM parameter. Product teams pick up the new version on their next deployment without code changes.
Approach 2: CDK Context Pinning
For teams that want explicit control over layer upgrades:
// cdk.context.json — Explicit layer version pins
{
"layer-versions": {
"runtime": "42",
"domain": "18",
"binary": "7"
}
}
This requires a PR to bump versions but provides a clear audit trail of exactly which layer version each function uses.
Benchmarks: Layers vs. Bundled vs. Docker Images
We compared three dependency management strategies across our fleet:
| Metric | Bundled (esbuild) | Layers (3 layers) | Docker Image |
|---|---|---|---|
| Deploy package size | 2-45MB per function | 0.5-2MB per function | 200-400MB image |
| Deploy time (code-only change) | 8-15 sec | 3-5 sec | 45-90 sec |
| Cold start (p50) | 180ms | 195ms | 350ms |
| Dependency version consistency | Per-function | Per-layer (shared) | Per-image |
| Max dependencies supported | 250MB limit | 250MB (5 layers × 50MB) | 10GB |
| Native binary support | Requires Docker bundling | Dedicated binary layer | Native |
Our recommendation: Use esbuild bundling for function-specific code, layers for shared runtime dependencies (SDK, Powertools, utilities), and Docker images only when you need native binaries that exceed layer size limits.
Anti-Patterns to Avoid
-
The "everything in one layer" mistake — A single 200MB layer with all possible dependencies. Slow to update, impossible to version granularly.
-
Layer-per-function — Defeats the purpose of sharing. Each function gets its own 50MB layer that nobody else uses.
-
Mutable layer references — Always pin to a specific layer version ARN. Using
$LATEST(if it existed) would cause non-deterministic deployments. -
Layers for frequently-changing code — If a shared library changes multiple times per sprint, the layer publishing overhead outweighs the sharing benefit. Bundle it instead.
-
Ignoring architecture compatibility — Layers built on x86 don't work on arm64 Lambda. Maintain separate layer builds per architecture, or standardize your fleet on one.
The Build Pipeline
Our layer publishing pipeline runs on every merge to the layers/ directory:
# .github/workflows/publish-layers.yml
name: Publish Lambda Layers
on:
push:
branches: [main]
paths: ['layers/**']
jobs:
publish:
runs-on: ubuntu-latest
strategy:
matrix:
layer: [runtime, domain, binary]
arch: [x86_64, arm64]
steps:
- uses: actions/checkout@v4
- name: Build layer
run: |
cd layers/${{ matrix.layer }}
docker build --platform linux/${{ matrix.arch == 'x86_64' && 'amd64' || 'arm64' }} -t layer-build .
docker cp $(docker create layer-build):/opt/layer.zip ./layer.zip
- name: Publish layer version
run: |
VERSION=$(aws lambda publish-layer-version \
--layer-name "${{ matrix.layer }}-${{ matrix.arch }}" \
--zip-file fileb://layer.zip \
--compatible-runtimes nodejs20.x \
--compatible-architectures ${{ matrix.arch }} \
--query 'Version' --output text)
aws ssm put-parameter \
--name "/layers/${{ matrix.layer }}-${{ matrix.arch }}/latest-version" \
--value "$VERSION" \
--type String \
--overwrite
This builds layers for both architectures, publishes new versions, and updates SSM parameters — all automated on merge.
Conclusion
Lambda Layers are a dependency management tool, not a silver bullet. They excel at sharing stable, infrequently-updated dependencies (AWS SDK, Powertools, native binaries) across many functions while keeping deployment packages small and deploy times fast.
The cold start concern is largely a myth at equivalent dependency sizes — layers don't add overhead versus bundling. What they do add is operational leverage: update a layer once, and 120 functions get the new dependency version on their next deploy.
Use layers for shared runtime dependencies, esbuild bundling for function-specific code, and Docker images for heavy native binary workloads. Pin versions explicitly (SSM or context), publish per-architecture, and automate the build pipeline. Your Lambda fleet will be smaller, faster to deploy, and consistent across functions.
Recommended reading

Per-Team Cost Allocation in Shared Kubernetes Clusters: From Chaos to Clarity
Implementing accurate per-namespace cost allocation in multi-tenant Kubernetes clusters, covering request vs. usage attribution, shared resource amortization, and building showback dashboards that drive accountability.

Measuring and Eliminating Toil: From 40% to 12% of Engineering Time
A systematic approach to identifying, measuring, and automating toil—the repetitive operational work that scales linearly with service growth and prevents engineers from doing creative work.

Serverless Postgres in Production: Branching, Scale-to-Zero, and the End of Database Provisioning
Running Neon serverless Postgres in production for 8 months — covering database branching workflows, scale-to-zero economics, connection pooling, and migration from RDS.

Comments
No comments yet. Be the first to share your thoughts.