Sooner or later a serverless project hits the wall: the deployment package is too big. A Python service pulls in pandas, pyarrow and a couple of ML libraries; a Node service needs headless Chromium for PDF rendering; a media pipeline needs ffmpeg. The zip blows past Lambda's 250 MB unzipped limit, and the team starts asking whether serverless was the right call at all.
It was. You just need a different packaging strategy. Lambda supports three of them — plain zip, zip plus layers, and OCI container images up to 10 GB — and Serverless Framework V4 can deploy all three. This post covers when each one is right, how to configure it, and what each choice costs you in cold start time and build complexity.
None of this is about writing different code. The handler stays the same; only how the bytes get to Lambda changes.
The limits that actually bind
Before choosing, know the numbers you are working against:
| Limit | Zip package | Container image |
|---|---|---|
| Deployment size | 50 MB zipped (direct), 250 MB unzipped, layers included | 10 GB image |
| Layers | 5 per function, counted in the 250 MB | n/a |
/tmp writable space | 512 MB, configurable to 10,240 MB | same |
| Console editing | Yes, under 3 MB | No |
| Cold start | Fastest | Slightly slower on first pull, comparable after |
Two things surprise people. First, the 250 MB is unzipped and includes your layers — stacking five layers does not buy you 250 MB each. Second, container images are not the cold-start disaster they were in 2020. Lambda caches and de-duplicates image layers, so a well-built image starts within tens of milliseconds of an equivalent zip. Image size matters far less than what your init code does.
Option 1: Stay on zip, and get smaller
Most "too big" packages are not actually big — they are careless. Before reaching for containers, spend an hour here.
Exclude what the runtime already provides. The AWS SDK v3 ships in the Node.js runtimes. Bundling it adds tens of megabytes for nothing:
build:
esbuild:
bundle: true
minify: true
target: node22
exclude:
- '@aws-sdk/*'
Package per function, not per service. By default every function in a service gets the same artifact, so your tiny cron handler ships the PDF renderer too. Turn on individual packaging and each function only carries what it imports:
package:
individually: true
patterns:
- '!tests/**'
- '!docs/**'
- '!**/*.md'
Check the result, do not assume it. serverless package writes artifacts to .serverless/; inspect them:
serverless package
ls -lhS .serverless/*.zip
unzip -l .serverless/api.zip | sort -k1 -n -r | head -20
For Python, the equivalent wins come from installing dependencies with --only-binary=:all: for the right platform and stripping tests, __pycache__, and .dist-info directories from site-packages. We have taken a 340 MB "impossible" Python package down to 90 MB this way more than once.
Option 2: Layers for shared, slow-moving dependencies
A layer is a zip that Lambda mounts at /opt before your handler runs. Layers are worth it when several functions share the same large, rarely-changing dependency set — because the layer is uploaded once and skipped on subsequent deploys of your fast-moving handler code.
layers:
vendor:
path: layers/vendor # layers/vendor/python/... or /nodejs/node_modules/...
name: ${self:service}-vendor-${sls:stage}
description: Shared runtime dependencies
compatibleRuntimes:
- python3.12
retain: false
functions:
report:
handler: src/report.handler
runtime: python3.12
layers:
- !Ref VendorLambdaLayer
Note the reference form: Serverless Framework exposes a layer named vendor as the CloudFormation logical ID VendorLambdaLayer. Getting that wrong is the most common layer error people hit.
Where layers disappoint:
- They still count against the 250 MB unzipped budget, so they do not solve the size problem — they solve the deploy speed and duplication problem.
- Local development diverges from production unless you replicate
/optin your test harness. - Cross-account sharing needs an explicit layer permission, and layer versions are immutable, so consumers pin to a version and drift.
Use layers for ffmpeg, a shared internal SDK, or the Powertools distribution. Do not use them as a general dependency manager.
Option 3: Container images, done properly
When the honest total exceeds 250 MB — most ML inference, anything with Chromium, large geospatial or scientific stacks — go to container images. Serverless Framework V4 builds the image, pushes it to ECR, and wires the function to it.
service: media-pipeline
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
ecr:
scanOnPush: true
images:
renderer:
path: ./
file: Dockerfile
platform: linux/arm64
buildArgs:
NODE_ENV: production
functions:
renderer:
image:
name: renderer
memorySize: 3008
timeout: 120
ephemeralStorageSize: 4096
Two details to get right. platform must match architecture — building an amd64 image on an Apple Silicon laptop for an arm64 function produces a function that fails at runtime with an exec format error, and the error message is not obvious. And ephemeralStorageSize is how you get more than the default 512 MB of /tmp, which matters for anything that writes intermediate files.
A workable Dockerfile, using the AWS base image so the runtime interface client is already present:
FROM public.ecr.aws/lambda/nodejs:22 AS build
WORKDIR /build
COPY package*.json ./
RUN npm ci --omit=dev
COPY src ./src
FROM public.ecr.aws/lambda/nodejs:22
COPY --from=build /build/node_modules ${LAMBDA_TASK_ROOT}/node_modules
COPY --from=build /build/src ${LAMBDA_TASK_ROOT}/src
CMD ["src/renderer.handler"]
Order your layers from least to most frequently changed — dependencies before application code — so that a code-only change re-pushes a few megabytes instead of the whole image.
For Python with heavy wheels, the same shape applies with public.ecr.aws/lambda/python:3.12, plus a --no-cache-dir install and a cleanup pass that deletes tests and cached bytecode.
What container images cost you
Be honest with your team about the trade-offs before committing:
- Build time. A zip build is seconds. An image build with a cold Docker cache is minutes, and CI runners without layer caching will feel it on every push. Enable buildx cache or a registry cache in your pipeline.
- ECR lifecycle. Every deploy pushes a new image. Without a lifecycle policy your repository grows forever and you pay storage on it. Set a rule that expires untagged images after 14 days.
- A second supply chain. You now own base image patching. Rebuild on a schedule, not only when the application changes, and turn on
scanOnPush. - Slower first invocation after a deploy. Lambda pulls and caches image layers on first use per environment. It is tens to a few hundred milliseconds, and it disappears once cached, but it is real for latency-critical paths.
If your service is under 250 MB after a genuine trimming pass, none of this is worth paying for.
Choosing, in one paragraph
Start with zip and individual packaging — it is the fastest to build, the fastest to start, and the easiest to debug. Add a layer when three or more functions share the same big, stable binary or dependency set and you want deploys to stop re-uploading it. Move to a container image only when the unzipped total genuinely cannot fit in 250 MB, or when you already run a container build pipeline and want one artifact format across Lambda, Fargate and ECS. Mixing is fine and common: one service can have zip-packaged API handlers and a single container-packaged worker, and Serverless Framework V4 will deploy both from the same serverless.yml.
Verify before you celebrate
Whichever route you take, confirm the reality in production rather than trusting the config:
# Package type and size as Lambda sees it
aws lambda get-function-configuration --function-name media-pipeline-dev-renderer \
--query '{Package:PackageType,Memory:MemorySize,Arch:Architectures,Tmp:EphemeralStorage}'
# Init duration on real invocations
aws logs start-query --log-group-name /aws/lambda/media-pipeline-dev-renderer \
--start-time $(date -d '1 hour ago' +%s) --end-time $(date +%s) \
--query-string 'filter @type="REPORT" | stats pct(@initDuration,95) as p95Init, max(@duration) as maxDur'
If p95 init got worse after a packaging change, the cause is almost always initialization code — a model loaded at module scope, a config file parsed on every cold start — not the packaging format itself.
Heavy-dependency Lambdas are where serverless projects most often get abandoned prematurely. If you are staring at a 300 MB package and deciding whether to move the workload to containers on ECS, get in touch — packaging, build pipelines and cold-start budgets for exactly these workloads are what our team does every day.