Guide · AWS Lambda · Lambda Layers

Using Lambda Layers for Shared Dependencies in MCP Tools

Lambda Layers let you extract the AWS SDK, Zod schemas, authentication middleware, and any other shared code into a separate deployment artifact that multiple MCP server Lambda functions reference as a read-only filesystem mount at /opt. The layer itself is versioned, immutable, and cached independently of your function code — so a 20MB node_modules bundle that previously inflated every function ZIP can be built once and shared across 10+ MCP tools, reducing per-function deploy size from 25MB to under 1MB and cutting cold-start time by 30–60%. For versioning and rollback strategies see Lambda Layers versioning guide; for cross-account sharing see cross-account Lambda Layers guide.

TL;DR

Create a nodejs/ directory, install your shared packages, zip it, and publish with aws lambda publish-layer-version. Attach the returned ARN to each MCP tool function with aws lambda update-function-configuration --layers. The Node.js runtime automatically adds /opt/nodejs/node_modules to NODE_PATH, so require('zod') just works with no special path. Keep the function ZIP under 1MB by excluding all packages that are in the layer, and mark them as external in your bundler with esbuild --external:zod --external:@aws-sdk/*.

Why layers matter for MCP server architectures

A typical MCP server built on Lambda has 5–15 tools, each deployed as its own Lambda function — one function for an S3 read tool, another for a DynamoDB query tool, another for a Secrets Manager lookup, and so on. Every one of those functions imports the same heavy dependencies: @aws-sdk/client-s3, @aws-sdk/client-secrets-manager, @aws-sdk/client-dynamodb, zod for input validation, and jose for JWT verification. Without layers, each function ZIP contains a full copy of every package it uses. With layers, those packages live at /opt/nodejs/node_modules inside the Lambda execution environment and are symlinked automatically by the Node.js runtime — no special import paths required.

The runtime prepends /opt/nodejs to NODE_PATH before your handler runs. This means import { z } from 'zod' at the top of your handler resolves against the layer's copy of Zod, not a copy bundled into the function ZIP. Lambda loads the layer from its own cache independently of loading your function code, so even on a cold start the layer bytes are not double-counted against your initialization time in the way a large function ZIP is.

MetricWithout layers (each function bundles deps)With layers (shared deps in layer)
Per-function deploy ZIP size20–30 MB (node_modules included)Under 1 MB (handler code only)
Total storage (10 functions, same deps)200–300 MB (10 copies of same packages)20–30 MB layer + ~10 MB total function ZIPs
Cold-start initialization time800–1,500 ms (large ZIP decompression)300–600 ms (small ZIP + cached layer)
Dependency update blast radiusRebuild and redeploy every function to update a shared depPublish one new layer version; update function ARN references
Deploy time (CI/CD)Upload 20–30 MB per function per deployUpload ~1 MB per function; layer upload only when deps change
Dependency drift riskEach function may drift to different package versionsAll functions use identical bytes from one layer version

The dependency drift risk is the most underappreciated benefit. In a multi-function MCP server without layers, it is common for mcp-tool-s3 to pin @aws-sdk/client-s3@3.100.0 while mcp-tool-dynamodb has drifted to 3.120.0 due to separate npm install runs. A security advisory that requires updating the SDK means auditing and rebuilding every function. With a shared layer, you rebuild the layer once, publish a new version, and update function ARN references in a single batch operation.

Building the layer ZIP

Lambda's Node.js runtime expects layer dependencies to be in a directory named exactly nodejs/ at the root of the ZIP. When Lambda mounts the layer at /opt, the runtime adds /opt/nodejs/node_modules to NODE_PATH. If you use a different directory name, the automatic path resolution does not work — you would have to use explicit paths like require('/opt/nodejs/node_modules/zod') in every file, which defeats the purpose.

# Build the shared dependencies layer ZIP for Node.js
# All dependencies go into nodejs/ so the runtime can find them at /opt/nodejs/node_modules

mkdir -p layer-build/nodejs
cd layer-build/nodejs

# Initialize a package.json — the name is arbitrary, used only for the layer build
npm init -y

# Install all shared dependencies
# Include every package that more than one MCP tool function imports
npm install \
  @aws-sdk/client-s3@3.650.0 \
  @aws-sdk/client-secrets-manager@3.650.0 \
  @aws-sdk/client-dynamodb@3.650.0 \
  @aws-sdk/lib-dynamodb@3.650.0 \
  @aws-sdk/client-sts@3.650.0 \
  jose@5.9.6 \
  zod@3.23.8

# Return to the layer root and zip
cd ..
zip -r layer.zip nodejs/

# Verify the ZIP structure — nodejs/ must be at the root
unzip -l layer.zip | head -20
# Archive:  layer.zip
#   Length      Date    Time    Name
# ---------  ---------- -----   ----
#         0  2026-10-10 00:00   nodejs/
#         0  2026-10-10 00:00   nodejs/node_modules/
#   ...
# The top-level directory MUST be nodejs/, not node_modules/ directly

ls -lh layer.zip
# Expect ~18-25 MB zipped (AWS SDK v3 modular clients are much smaller than v2)

Pin exact versions in the layer's package.json using npm install pkg@x.y.z rather than allowing semver ranges. The layer is immutable once published — a ~ or ^ range that resolves to a different patch version on a future layer rebuild would change behavior even though functions still reference the same layer ARN number. Use exact versions to make the layer bit-for-bit reproducible across rebuilds.

# Equivalent layer structure for Python 3.12
# Python layers must use python/lib/python3.12/site-packages/ instead of nodejs/

mkdir -p python-layer/python/lib/python3.12/site-packages
pip install \
  boto3==1.35.0 \
  pydantic==2.9.2 \
  python-jose==3.3.0 \
  --target python-layer/python/lib/python3.12/site-packages/

cd python-layer
zip -r python-layer.zip python/

# Verify structure
unzip -l python-layer.zip | head -10
# The top-level directory must be python/
# Runtime adds /opt/python/lib/python3.12/site-packages to sys.path automatically

# For Python 3.11: use python/lib/python3.11/site-packages/
# For a version-independent path: python/lib/python3.x/site-packages/ does NOT work;
# you must use the exact version string

One common mistake is building the layer on macOS and deploying to a linux/arm64 or linux/x86_64 Lambda runtime. Pure JavaScript packages are fine — they have no native binaries. But packages with native C++ addons (like argon2 or bcrypt) will fail at runtime with Error: /opt/nodejs/node_modules/bcrypt/lib/binding/napi-v3/bcrypt_lib.node: invalid ELF header. Build those layers inside a Linux Docker container that matches the Lambda execution environment:

# Build a layer inside the Lambda Node.js 22 Docker image to ensure binary compatibility
docker run --rm \
  -v "$(pwd)/layer-build:/var/task" \
  public.ecr.aws/lambda/nodejs:22 \
  bash -c "cd /var/task/nodejs && npm install && exit"

# Then zip from the host
cd layer-build
zip -r layer.zip nodejs/

Publishing and attaching the layer

Publishing a layer version creates an immutable artifact in Lambda's internal storage and returns a versioned ARN. Each subsequent publish-layer-version call with the same layer name creates a new version — the previous version is never overwritten. Store the returned ARN; you will need it when attaching the layer to each function.

# Publish the layer to Lambda
# --layer-name is the logical name; versions are appended automatically (:1, :2, ...)
# --compatible-runtimes must match the runtimes of functions that will use this layer
# --compatible-architectures restricts the layer to x86_64 or arm64 functions
LAYER_ARN=$(aws lambda publish-layer-version \
  --layer-name mcp-shared-deps \
  --description "Shared dependencies for MCP server tools: AWS SDK v3, zod, jose" \
  --zip-file fileb://layer-build/layer.zip \
  --compatible-runtimes nodejs20.x nodejs22.x \
  --compatible-architectures x86_64 arm64 \
  --region us-east-1 \
  --query 'LayerVersionArn' \
  --output text)

echo "Published layer ARN: $LAYER_ARN"
# arn:aws:lambda:us-east-1:123456789012:layer:mcp-shared-deps:1

# Get the version number for reference
LAYER_VERSION=$(aws lambda list-layer-versions \
  --layer-name mcp-shared-deps \
  --region us-east-1 \
  --query 'LayerVersions[0].Version' \
  --output text)
echo "Layer version: $LAYER_VERSION"
# Attach the layer to an existing MCP tool function
# --layers accepts up to 5 layer ARNs; order matters (later layers win on path conflicts)
aws lambda update-function-configuration \
  --function-name mcp-tool-s3 \
  --layers "$LAYER_ARN" \
  --region us-east-1

# Wait for the update to complete before invoking
aws lambda wait function-updated \
  --function-name mcp-tool-s3 \
  --region us-east-1

# Verify the layer is attached
aws lambda get-function-configuration \
  --function-name mcp-tool-s3 \
  --query 'Layers' \
  --region us-east-1
# [{ "Arn": "arn:aws:lambda:...:layer:mcp-shared-deps:1", "CodeSize": 22345678 }]

Lambda allows up to 5 layers per function. When multiple layers provide a module with the same name, the layer listed last in the --layers argument takes precedence — it is added last to NODE_PATH, so it wins during module resolution. A practical pattern for MCP servers is two layers: one for stable AWS SDK packages (rarely updated) and one for your own shared utilities and validation schemas (updated more frequently). The AWS SDK layer is listed first; your utilities layer is listed second so it can override anything in the first layer if necessary.

# Attach two layers: AWS SDK layer first, shared utilities layer second
# The second layer's packages shadow the first on any name conflict
aws lambda update-function-configuration \
  --function-name mcp-tool-s3 \
  --layers \
    "arn:aws:lambda:us-east-1:123456789012:layer:mcp-aws-sdk:3" \
    "arn:aws:lambda:us-east-1:123456789012:layer:mcp-shared-utils:12" \
  --region us-east-1

# Batch-attach both layers to all 10 MCP tool functions
MCP_FUNCTIONS=(
  mcp-tool-s3
  mcp-tool-dynamodb
  mcp-tool-secrets
  mcp-tool-sqs
  mcp-tool-sns
  mcp-tool-ec2
  mcp-tool-iam
  mcp-tool-cloudwatch
  mcp-tool-bedrock
  mcp-tool-sts
)

SDK_LAYER_ARN="arn:aws:lambda:us-east-1:123456789012:layer:mcp-aws-sdk:3"
UTILS_LAYER_ARN="arn:aws:lambda:us-east-1:123456789012:layer:mcp-shared-utils:12"

for fn in "${MCP_FUNCTIONS[@]}"; do
  echo "Attaching layers to $fn..."
  aws lambda update-function-configuration \
    --function-name "$fn" \
    --layers "$SDK_LAYER_ARN" "$UTILS_LAYER_ARN" \
    --region us-east-1 \
    --no-cli-pager
  aws lambda wait function-updated --function-name "$fn" --region us-east-1
done
echo "Done."

Using the layer in your MCP tool Lambda

Once the layer is attached, your handler code imports packages exactly as if they were in a local node_modules — no special path prefix, no runtime require tricks. The Node.js runtime prepends /opt/nodejs/node_modules to the module resolution search path before your handler initializes, so the standard Node.js module resolution algorithm finds the layer packages transparently.

Crucially, the layer is not bundled into the function ZIP. When you deploy a function that relies on a layer, your ZIP contains only your handler code. This is what creates the size reduction: a 25MB function ZIP that previously contained @aws-sdk and zod becomes a 200KB ZIP that contains only the handler. The layer bytes are loaded from Lambda's internal layer cache at cold-start time.

// mcp-tool-s3/index.ts — handler for an MCP S3 read tool
// These imports resolve against /opt/nodejs/node_modules/ at runtime
// The function ZIP contains ONLY this handler code — no node_modules directory
import { z } from "zod";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";

// Input schema defined with zod (from the layer)
const GetObjectInput = z.object({
  bucket: z.string().min(3).max(63),
  key: z.string().min(1).max(1024),
  versionId: z.string().optional(),
});

// AWS SDK clients initialized at module scope (outside handler) for connection reuse
const s3 = new S3Client({ region: process.env.AWS_REGION });
const secrets = new SecretsManagerClient({ region: process.env.AWS_REGION });

export const handler = async (event: unknown) => {
  // Validate input using zod from the layer
  const parseResult = GetObjectInput.safeParse(event);
  if (!parseResult.success) {
    return {
      isError: true,
      content: [{ type: "text", text: `Invalid input: ${parseResult.error.message}` }],
    };
  }
  const { bucket, key, versionId } = parseResult.data;

  try {
    const response = await s3.send(
      new GetObjectCommand({ Bucket: bucket, Key: key, VersionId: versionId })
    );
    const body = await response.Body?.transformToString();
    return {
      content: [{ type: "text", text: body ?? "" }],
    };
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : String(err);
    return {
      isError: true,
      content: [{ type: "text", text: `S3 error: ${message}` }],
    };
  }
};
# The function ZIP contains ONLY the compiled handler — no node_modules
# Build the handler without bundling layer packages
# Using esbuild: mark all layer packages as external
npx esbuild mcp-tool-s3/index.ts \
  --bundle \
  --platform=node \
  --target=node22 \
  --external:zod \
  --external:"@aws-sdk/*" \
  --external:jose \
  --outfile=dist/index.js \
  --minify

# Zip only the compiled handler
cd dist && zip -r ../function.zip index.js && cd ..
ls -lh function.zip
# function.zip: ~150 KB — compared to ~25 MB if packages were bundled

# Deploy the function (assumes role and VPC config already exist)
aws lambda update-function-code \
  --function-name mcp-tool-s3 \
  --zip-file fileb://function.zip \
  --region us-east-1

If you are using TypeScript and compile to .js before zipping, the --external flags tell esbuild not to follow the import and not to include the package in the output bundle. At runtime, Node.js resolves the import through the normal module resolution algorithm, which reaches /opt/nodejs/node_modules via NODE_PATH. If you forget --external, esbuild bundles the package into the ZIP, the layer is still mounted, and the function works — but the size reduction is lost because the function ZIP carries a duplicate copy.

IaC: CDK and SAM

Both CDK and SAM can define layers as first-class resources and automatically attach them to functions. CDK's LayerVersion construct computes a content hash of the layer asset directory and creates a new layer version only when the content changes, making incremental deploys efficient.

// CDK: Define the shared dependencies layer and attach it to MCP tool functions
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as path from "path";
import { RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";

export class McpServerStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // Layer: shared AWS SDK and utility dependencies
    // Code.fromAsset points to the nodejs/ directory (CDK zips it automatically)
    // RemovalPolicy.RETAIN prevents accidental deletion of a version still referenced by a function
    const sharedDepsLayer = new lambda.LayerVersion(this, "McpSharedDeps", {
      layerVersionName: "mcp-shared-deps",
      code: lambda.Code.fromAsset(path.join(__dirname, "../layers/shared-deps")),
      compatibleRuntimes: [
        lambda.Runtime.NODEJS_20_X,
        lambda.Runtime.NODEJS_22_X,
      ],
      compatibleArchitectures: [
        lambda.Architecture.X86_64,
        lambda.Architecture.ARM_64,
      ],
      description: "Shared deps for MCP tools: AWS SDK v3, zod, jose",
      removalPolicy: RemovalPolicy.RETAIN,
      // RETAIN is critical: if CDK tries to delete this layer version during a stack update
      // and a function still references it, the function becomes non-deployable.
      // RETAIN leaves the layer version in place even when the CDK construct is removed.
    });

    // Define all MCP tool functions — each gets the shared layer attached
    const toolNames = [
      "mcp-tool-s3",
      "mcp-tool-dynamodb",
      "mcp-tool-secrets",
      "mcp-tool-sqs",
    ];

    for (const toolName of toolNames) {
      new lambda.Function(this, toolName, {
        functionName: toolName,
        runtime: lambda.Runtime.NODEJS_22_X,
        handler: "index.handler",
        code: lambda.Code.fromAsset(path.join(__dirname, `../tools/${toolName}/dist`)),
        layers: [sharedDepsLayer],
        memorySize: 256,
        timeout: cdk.Duration.seconds(30),
        environment: {
          NODE_OPTIONS: "--enable-source-maps",
        },
      });
    }
  }
}

The CDK asset directory for layers/shared-deps should contain the nodejs/ subdirectory that CDK will zip. The directory structure on disk mirrors the ZIP structure Lambda expects:

# Directory layout for the CDK layer asset
# CDK zips the entire shared-deps/ directory, so nodejs/ must be inside it
layers/
  shared-deps/
    nodejs/
      package.json
      node_modules/
        zod/
        @aws-sdk/
        jose/
        ...

# Build script — run before cdk deploy
cd layers/shared-deps/nodejs
npm ci --omit=dev  # install exact versions from package-lock.json
cd ../../..
# Now cdk deploy will pick up the new node_modules and create a new layer version
# if the content hash has changed
# SAM equivalent: AWS::Serverless::LayerVersion
# template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31

Globals:
  Function:
    Runtime: nodejs22.x
    MemorySize: 256
    Timeout: 30

Resources:

  McpSharedDepsLayer:
    Type: AWS::Serverless::LayerVersion
    Properties:
      LayerName: mcp-shared-deps
      Description: "Shared deps for MCP tools: AWS SDK v3, zod, jose"
      ContentUri: layers/shared-deps/
      CompatibleRuntimes:
        - nodejs20.x
        - nodejs22.x
      CompatibleArchitectures:
        - x86_64
        - arm64
      RetentionPolicy: Retain
      # RetentionPolicy: Retain prevents SAM from deleting the layer version
      # on stack update — same reasoning as CDK's RemovalPolicy.RETAIN
    Metadata:
      BuildMethod: nodejs22.x  # SAM builds the layer using npm ci

  McpToolS3:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: mcp-tool-s3
      Handler: index.handler
      CodeUri: tools/mcp-tool-s3/
      Layers:
        - !Ref McpSharedDepsLayer
      Environment:
        Variables:
          NODE_OPTIONS: "--enable-source-maps"
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        Minify: true
        Target: es2022
        External:
          - zod
          - "@aws-sdk/*"
          - jose

The RemovalPolicy.RETAIN (CDK) / RetentionPolicy: Retain (SAM/CloudFormation) setting deserves emphasis. Lambda layer versions are immutable — you cannot modify a published version. When your CDK or SAM stack creates a new layer version (because the asset hash changed), the old version is not automatically deleted. If you set RemovalPolicy.DESTROY, CDK will attempt to delete the old layer version on the next deploy. If any function — in this stack or another — still references that version's ARN, the delete fails, or the function is left referencing a deleted layer, which causes ResourceNotFoundException on the next cold start or configuration update. RemovalPolicy.RETAIN is the safe default; clean up old layer versions manually after confirming no functions reference them.

Layer size limits and best practices

Lambda enforces two size constraints relevant to layers. The unzipped size of the function code plus all attached layers combined must not exceed 250MB. An individual layer ZIP cannot exceed 50MB when uploaded directly; ZIPs larger than 50MB must be uploaded via S3 using --content S3Bucket=...,S3Key=... instead of --zip-file. The 50MB compressed limit rarely causes problems with Node.js AWS SDK v3 (modular clients) but can be reached with Python scientific libraries (numpy, pandas) or with Node.js AWS SDK v2 (which was monolithic).

LimitValueWhat triggers it
Layer ZIP upload (direct)50 MB compressedSingle publish-layer-version --zip-file call
Layer ZIP upload (via S3)250 MB compressedUsing --content S3Bucket=... S3Key=...
Total unzipped size (all layers + function code)250 MBSum of all attached layer unzipped sizes plus the function ZIP unzipped
Number of layers per function5Attaching a 6th layer with --layers
Layer versions per layer nameNo hard limit (soft: 100 per account)Each publish-layer-version increments the version counter

The best practice for staying under the 250MB unzipped limit is to split layers by update frequency rather than by function. A single monolithic layer containing every dependency is the simplest option but has high blast radius — every function must be updated whenever any dependency changes. A better split:

# Check how large an existing layer version is (unzipped)
aws lambda get-layer-version \
  --layer-name mcp-shared-deps \
  --version-number 3 \
  --region us-east-1 \
  --query '{CodeSize:Content.CodeSize,UnzippedSize:Content.UnzippedCodeSize}' \
  --output table
# -------------------------------------
# |       GetLayerVersion             |
# +-----------------+-----------------+
# |    CodeSize     |  UnzippedSize   |
# +-----------------+-----------------+
# |  19234567       |  84123456       |
# +-----------------+-----------------+
# CodeSize: 19.2 MB compressed; UnzippedSize: 84.1 MB — well within 250 MB

# Check total size usage across all layers attached to a function
aws lambda get-function-configuration \
  --function-name mcp-tool-s3 \
  --query 'Layers[*].{Arn:Arn,CodeSize:CodeSize}' \
  --region us-east-1

# Audit what is actually inside a layer ZIP before publishing it
# Download the layer ZIP (accessible for 10 minutes via the pre-signed URL)
LAYER_URL=$(aws lambda get-layer-version \
  --layer-name mcp-shared-deps \
  --version-number 3 \
  --query 'Content.Location' \
  --output text)
curl -s "$LAYER_URL" -o /tmp/layer-inspect.zip
unzip -l /tmp/layer-inspect.zip | sort -k1 -n -r | head -20
# Shows the largest files in the layer — useful for finding unexpectedly bundled packages
# Upload a layer larger than 50 MB via S3
aws s3 cp layer-build/layer.zip s3://my-deployment-bucket/layers/mcp-shared-deps-v4.zip

aws lambda publish-layer-version \
  --layer-name mcp-shared-deps \
  --description "v4: updated AWS SDK to 3.700.0" \
  --content S3Bucket=my-deployment-bucket,S3Key=layers/mcp-shared-deps-v4.zip \
  --compatible-runtimes nodejs20.x nodejs22.x \
  --compatible-architectures x86_64 arm64 \
  --region us-east-1 \
  --query 'LayerVersionArn' \
  --output text

AliveMCP and Lambda-based MCP server monitoring

When your MCP tools run as Lambda functions, standard uptime monitors that issue a plain TCP connection check or an HTTP GET to your function URL are not sufficient. A Lambda function can return HTTP 200 with a Lambda error payload in the body — {"errorMessage": "Cannot find module 'zod'", "errorType": "Error"} — while the HTTP status is 200. This happens when the layer is attached but the ZIP structure is wrong, or when compatibleRuntimes does not match the function runtime. A monitor that only checks for HTTP 200 will report the function as healthy when every MCP tool invocation is silently failing.

AliveMCP probes your Lambda function URL or API Gateway endpoint and parses the response body to differentiate between a 200 with a valid MCP JSON-RPC response, a 200 with a Lambda error payload (runtime exception), a 5xx from Lambda's invoke infrastructure (function throttle, resource limit), and a cold-start timeout (the function initialized too slowly and Lambda returned a timeout before the handler could respond). Each of these produces a different alert type in AliveMCP, so you can distinguish "layer dependency missing" from "function just needs more memory" from "your API Gateway stage is misconfigured" — without trawling CloudWatch Logs manually.

Configure AliveMCP with a test MCP tool invocation payload (a minimal tools/call JSON-RPC request) and attach it to every function URL in your MCP server. AliveMCP checks every 60 seconds and alerts you via Slack, PagerDuty, or email within 60 seconds of the first failed probe — before your users encounter a broken tool call in Claude Desktop or whichever MCP client they are using.

Failure modes

SymptomCauseFix
Cannot find module 'zod' at runtimeLayer attached but runtime is different from the layer's compatibleRuntimes; or layer ZIP does not have nodejs/ as the top-level directory (e.g., node_modules/ is at root)Verify compatibleRuntimes matches the function runtime with aws lambda get-layer-version --layer-name ... --version-number ... --query 'CompatibleRuntimes'; check layer ZIP structure with unzip -l layer.zip | head — first entry must be nodejs/
Function size limit exceeded on deployFunction ZIP plus all attached layers exceeds 250MB unzippedSplit dependencies by update frequency into separate layers; audit what is in each layer with unzip -l; remove dev dependencies from the layer with npm install --omit=dev
Layer version not found / ResourceNotFoundExceptionFunction references a layer version ARN where the version was deleted (manual delete or RemovalPolicy.DESTROY during a CDK deploy)Use RemovalPolicy.RETAIN in CDK and RetentionPolicy: Retain in SAM; never manually delete a layer version without first verifying no function references it
Cold start not improved after adding layersDependencies are still bundled in the function ZIP because the bundler is including them (esbuild without --external flags)Mark all layer packages as external in esbuild: --external:zod --external:"@aws-sdk/*" --external:jose; verify the function ZIP contains no node_modules directory: unzip -l function.zip | grep node_modules should return empty
Layer update not picked up by function after publishing new versionFunction configuration still references the old layer version ARN explicitly — Lambda does not auto-update layer ARN referencesUpdate the function configuration: aws lambda update-function-configuration --function-name ... --layers $NEW_LAYER_ARN; CDK handles this automatically on cdk deploy when the layer asset hash changes
/opt/nodejs/node_modules not on PATHLayer is structured for Node.js but function runtime is Python (or vice versa); or the ZIP uses nodejs/lib/node_modules/ instead of nodejs/node_modules/Layers are runtime-specific — check compatibleRuntimes on the layer; ensure the ZIP top-level directory matches the runtime: nodejs/ for Node.js, python/lib/python3.12/site-packages/ for Python 3.12