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.

#aws#lambda#layers#dependency-management
Cover image for the article: Lambda Layer Strategies for Shared Dependencies at Scale

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.

Lambda Layers Architecture

Architecture: Layer Types and Use Cases

We categorize layers into three types based on update frequency and scope:

  1. Runtime Layer — AWS SDK, Powertools, shared utilities. Updated monthly.
  2. Business Logic Layer — Domain-specific shared libraries (validation schemas, API clients). Updated per sprint.
  3. 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:

MetricBundled (esbuild)Layers (3 layers)Docker Image
Deploy package size2-45MB per function0.5-2MB per function200-400MB image
Deploy time (code-only change)8-15 sec3-5 sec45-90 sec
Cold start (p50)180ms195ms350ms
Dependency version consistencyPer-functionPer-layer (shared)Per-image
Max dependencies supported250MB limit250MB (5 layers × 50MB)10GB
Native binary supportRequires Docker bundlingDedicated binary layerNative

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

  1. The "everything in one layer" mistake — A single 200MB layer with all possible dependencies. Slow to update, impossible to version granularly.

  2. Layer-per-function — Defeats the purpose of sharing. Each function gets its own 50MB layer that nobody else uses.

  3. Mutable layer references — Always pin to a specific layer version ARN. Using $LATEST (if it existed) would cause non-deterministic deployments.

  4. 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.

  5. 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.

Comments

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