DEV Community

Cover image for SOCI lazy loading: start Lambda containers faster
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

SOCI lazy loading: start Lambda containers faster

SOCI lazy loading lets a Lambda function on Lambda Managed Instances start before its container image has been fully downloaded. AWS announced it on October 28, 2026, and quotes startup times that are up to 70 percent shorter. I care less about that percentage than about two questions: who actually benefits from the extra build step, and where does it cause you trouble?

Container images are often the escape hatch on Lambda for anything that does not fit into a 250 MB ZIP package: ML libraries, models, PDF tools, entire runtimes. Those images quickly grow to several gigabytes. In this article, we build an example from scratch together: a small book recommendation service for a library, an image with four language models, the SOCI index, a CDK stack with a capacity provider and a script that lets you measure the effect yourself.

In short

SOCI places a table of contents next to your container image, so Lambda on Managed Instances only fetches the files your function actually reads. It pays off most for large images where each function needs only part of the content. The biggest pitfall is the format: Lambda accepts only a SOCI index v2 inside an image index with exactly two entries, and only single-architecture images.

What you will learn in this article

  • What Lambda Managed Instances are and when they fit your workload
  • How SOCI lazy loading fetches files on demand
  • How to create a SOCI index with nerdctl and push it to ECR
  • How to set up a capacity provider and function with AWS CDK in TypeScript
  • How to measure startup times with and without SOCI yourself

What are Lambda Managed Instances?

Lambda Managed Instances (LMI for short) are a second way to run Lambda functions. Instead of the shared Lambda fleet, your functions run on EC2 instances in your own account. Lambda still takes care of launching, patching, routing and scaling those instances. You only decide where they may run and on which hardware.

The central building block is the capacity provider. It describes the VPC, subnets, security groups, architecture, allowed instance types and an upper limit for vCPUs. You then attach a function to that provider and publish a version. Only when you publish does Lambda launch instances, three by default for spreading across Availability Zones, plus three execution environments, before the version counts as ACTIVE.

How it differs from regular Lambda

Lambda (default) Lambda Managed Instances
Invocations per environment one at a time several at the same time
Isolation Firecracker microVMs on a shared fleet containers on EC2 Nitro instances in your account
Billing per request and duration EC2 price plus a 15% management fee
Scaling on demand, down to zero asynchronously based on CPU utilization, not to zero
Good fit for irregular traffic high, predictable traffic

For Node.js, LMI uses worker threads, and initialization runs once per worker thread. That matters for our example: if you load a large model into memory, expect it to sit in memory once per thread. In addition, concurrent requests in the same thread share the global state. Anything you create outside the handler should therefore only be read and never modified.

If you want to refresh the regular Lambda basics first, my article Deploy a Lambda function with CDK and TypeScript covers the standard path with a ZIP package.

Why large images slow down Managed Instances

Without SOCI, Lambda has to download and unpack the complete image on every instance before an execution environment can start there. That does not happen just once when you publish. It also happens whenever the capacity provider launches new instances because load is rising. With a 5 GB image, you are mostly waiting on network and disk during this phase.

The catch: a large part of that data is often never read. An image with PyTorch, several models and half an operating system contains far more than a single invocation needs. This is where SOCI lazy loading helps.

How SOCI lazy loading works

SOCI stands for Seekable OCI. OCI is the open standard for container images. The project comes from AWS and is open source under Apache-2.0. SOCI does not change your image. Instead, it creates an additional index that records for each file in which layer and at which position it is stored. With this table of contents, Lambda can fetch individual files from ECR instead of downloading everything in one go.

On Lambda Managed Instances, the flow looks like this: you push the image and the SOCI index together to ECR under a separate tag. That tag points to an image index with two entries, the regular image manifest and the SOCI index. If your function points to this tag, Lambda detects the index when you publish the version and turns on lazy loading. There are no further settings.

🎬 Startup with and without SOCI – this animated diagram only runs on my site: watch it on sascha-ms.de

When the files are fetched

Files your init code reads are fetched by Lambda during initialization. Anything the handler touches only later gets pulled in during that invocation, which then takes correspondingly longer. According to the documentation, Lambda does not download the rest of the image in the background. That gives you the most important rule for your code: import every library and read every file you need in the init phase. Then your invocation durations stay consistent.

Diagram: five steps from an arm64 image via nerdctl to a SOCI index and a Lambda function using tag v1-soci

From a plain image to an image index with a SOCI index: five steps to the function.

Prerequisites for SOCI lazy loading

  • An AWS account in a Region where Lambda Managed Instances are available, and AWS CLI v2
  • Node.js 22 or later, Docker with buildx and AWS CDK v2 (here aws-cdk-lib 2.275.0)
  • For the SOCI step, a Linux machine with containerd, for example a small EC2 instance with Amazon Linux 2023 and a Graviton processor
  • A function packaged as a container image for one architecture, here arm64
  • The jq command line tool and basic knowledge of TypeScript and CDK

A quick glossary: ECR is the AWS container registry. nerdctl is a Docker-like command line tool for containerd, and the SOCI snapshotter is the containerd extension that can create and read SOCI indexes.

Setting up SOCI lazy loading step by step

Our example is a recommendation service for a library. For every book ID, it returns the five most similar books. It computes the similarity from vectors stored in one binary file per language. The image contains models for German, English, French and Spanish, but the function reads only one of them. I chose this on purpose, because exactly this pattern shows up in real images all the time.

Step 1: Create the project

mkdir lmi-soci-library && cd lmi-soci-library
mkdir -p src scripts lib bin test
npm init -y
Enter fullscreen mode Exit fullscreen mode

Replace the generated package.json with this version. "type": "module" makes Node.js load the compiled files as ES modules, including inside the container.

{
  "name": "lmi-soci-library",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc -p tsconfig.build.json",
    "models": "node --experimental-strip-types scripts/make-models.ts",
    "test": "node --experimental-strip-types --test test/*.test.ts",
    "measure": "node --experimental-strip-types scripts/measure-start.ts"
  },
  "dependencies": {},
  "devDependencies": {
    "@aws-sdk/client-lambda": "3.1159.0",
    "@types/node": "^22.19.0",
    "aws-cdk": "2.1146.1",
    "aws-cdk-lib": "2.275.0",
    "constructs": "^10.4.2",
    "typescript": "7.0.2"
  }
}
Enter fullscreen mode Exit fullscreen mode

You run the scripts under scripts/ and the tests directly from TypeScript, using the Node.js 22 option --experimental-strip-types. For the container, on the other hand, you compile to dist/. Two small configuration files handle this:

// tsconfig.json
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true,
    "skipLibCheck": true,
    "types": ["node"],
    "outDir": "dist"
  },
  "include": ["src", "scripts", "lib", "bin", "test"]
}
Enter fullscreen mode Exit fullscreen mode
// tsconfig.build.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": { "rootDir": "src", "outDir": "dist" },
  "include": ["src"]
}
Enter fullscreen mode Exit fullscreen mode
npm install
Enter fullscreen mode Exit fullscreen mode

Step 2: Load and compare vectors

The module reads a file in the format “magic bytes, count, dimensions, values” and compares books using cosine similarity. Cosine similarity measures how closely two vectors point in the same direction: 1 means very similar, 0 means unrelated.

// src/vectors.ts
import { readFileSync } from 'node:fs';

export interface BookVectors {
  count: number;
  dims: number;
  data: Float32Array;
}

const MAGIC = 'BKV1';
const HEADER_BYTES = 12;

/** Writes vectors in a simple binary format: "BKV1", count, dims, float32 values. */
export function encodeVectors(v: BookVectors): Buffer {
  const header = Buffer.alloc(HEADER_BYTES);
  header.write(MAGIC, 0, 'ascii');
  header.writeUInt32LE(v.count, 4);
  header.writeUInt32LE(v.dims, 8);
  const body = Buffer.from(v.data.buffer, v.data.byteOffset, v.data.byteLength);
  return Buffer.concat([header, body]);
}

export function decodeVectors(buf: Buffer): BookVectors {
  if (buf.subarray(0, 4).toString('ascii') !== MAGIC) {
    throw new Error('Unknown file format: magic bytes missing');
  }
  const count = buf.readUInt32LE(4);
  const dims = buf.readUInt32LE(8);
  const expected = HEADER_BYTES + count * dims * 4;
  if (buf.byteLength !== expected) {
    throw new Error(`File size ${buf.byteLength} does not match ${expected} bytes`);
  }
  // Copy into an aligned buffer so Float32Array works on every offset
  const data = new Float32Array(count * dims);
  data.set(new Float32Array(buf.buffer.slice(buf.byteOffset + HEADER_BYTES, buf.byteOffset + expected)));
  return { count, dims, data };
}

export function loadVectors(path: string): BookVectors {
  return decodeVectors(readFileSync(path));
}

function cosine(v: BookVectors, a: number, b: number): number {
  let dot = 0;
  let na = 0;
  let nb = 0;
  const oa = a * v.dims;
  const ob = b * v.dims;
  for (let i = 0; i < v.dims; i++) {
    const x = v.data[oa + i];
    const y = v.data[ob + i];
    dot += x * y;
    na += x * x;
    nb += y * y;
  }
  return na === 0 || nb === 0 ? 0 : dot / Math.sqrt(na * nb);
}

export interface Recommendation {
  bookId: number;
  score: number;
}

/** Returns the books most similar to bookId (without bookId itself). */
export function similarBooks(v: BookVectors, bookId: number, limit = 5): Recommendation[] {
  if (!Number.isInteger(bookId) || bookId < 0 || bookId >= v.count) {
    throw new RangeError(`bookId must be between 0 and ${v.count - 1}`);
  }
  const best: Recommendation[] = [];
  for (let other = 0; other < v.count; other++) {
    if (other === bookId) continue;
    const score = cosine(v, bookId, other);
    if (best.length < limit || score > best[best.length - 1].score) {
      best.push({ bookId: other, score });
      best.sort((x, y) => y.score - x.score);
      if (best.length > limit) best.pop();
    }
  }
  return best.map((r) => ({ bookId: r.bookId, score: Math.round(r.score * 1000) / 1000 }));
}
Enter fullscreen mode Exit fullscreen mode

Step 3: A handler that loads in the init phase

The handler loads the model at the top level, which means during initialization. With SOCI, this is the moment Lambda fetches the file from ECR. If the call to loadVectors lived inside the handler, the first invocation of every environment would take noticeably longer.

// src/handler.ts
import { loadVectors, similarBooks, type Recommendation } from './vectors.ts';

// Load in the init phase: with SOCI, Lambda fetches the file here
// and not during the first invocation.
const MODEL_FILE = process.env.MODEL_FILE ?? '/opt/models/books-de.bin';
const started = Date.now();
const vectors = loadVectors(MODEL_FILE);
console.log(JSON.stringify({ msg: 'model loaded', file: MODEL_FILE, books: vectors.count, ms: Date.now() - started }));

export interface RecommendEvent {
  bookId: number;
  limit?: number;
}

export interface RecommendResult {
  model: string;
  recommendations: Recommendation[];
}

// Read-only shared state: safe even when an execution environment
// handles several invocations at the same time.
export async function handler(event: RecommendEvent): Promise<RecommendResult> {
  const limit = Math.min(Math.max(event.limit ?? 5, 1), 20);
  return { model: MODEL_FILE, recommendations: similarBooks(vectors, event.bookId, limit) };
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Generate test data and check locally

The script creates four model files with random values. With BOOKS=200000, every file grows to roughly 100 MB, and the difference at startup becomes clearly visible. Small files are enough for the first test.

// scripts/make-models.ts
// Generates one vector file per language. Only one of them is used per function –
// exactly the situation in which SOCI lazy loading pays off.
import { mkdirSync, writeFileSync } from 'node:fs';
import { encodeVectors } from '../src/vectors.ts';

const count = Number(process.env.BOOKS ?? 20000);
const dims = Number(process.env.DIMS ?? 128);
const languages = (process.env.LANGS ?? 'de,en,fr,es').split(',');

mkdirSync('models', { recursive: true });
let seed = 42;
const random = () => {
  seed = (seed * 1664525 + 1013904223) % 2 ** 32;
  return seed / 2 ** 32 - 0.5;
};

for (const lang of languages) {
  const data = new Float32Array(count * dims);
  for (let i = 0; i < data.length; i++) data[i] = random();
  const file = `models/books-${lang}.bin`;
  const buf = encodeVectors({ count, dims, data });
  writeFileSync(file, buf);
  console.log(`${file}: ${(buf.byteLength / 1024 / 1024).toFixed(1)} MB`);
}
Enter fullscreen mode Exit fullscreen mode
BOOKS=5000 npm run models
npm test
npm run build
MODEL_FILE=models/books-de.bin node -e "import('./dist/handler.js').then(async (m) => console.log(await m.handler({ bookId: 7, limit: 3 })))"
Enter fullscreen mode Exit fullscreen mode

Expected result: four files of 2.4 MB each, four passing tests and an output with three recommendations. Book IDs and scores are random; the structure looks like this:

{"msg":"model loaded","file":"models/books-de.bin","books":5000,"ms":4}
{
  model: 'models/books-de.bin',
  recommendations: [
    { bookId: 37, score: 0.337 },
    { bookId: 1965, score: 0.311 },
    { bookId: 3426, score: 0.274 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Build the image for arm64 and push it to ECR

The Dockerfile uses the official Lambda base image for Node.js 24. The models end up in /opt/models.

# Dockerfile
FROM public.ecr.aws/lambda/nodejs:24

# Compiled code plus package.json (for "type": "module")
COPY package.json ${LAMBDA_TASK_ROOT}/
COPY dist/ ${LAMBDA_TASK_ROOT}/

# All language models – each function only reads one of them
COPY models/ /opt/models/

CMD ["handler.handler"]
Enter fullscreen mode Exit fullscreen mode

Two build options matter here. --platform linux/arm64, because Lambda accepts only single-architecture images. And --provenance=false, because buildx would otherwise attach an attestation to the image and turn it into an image index with several entries. For the SOCI step, you want a plain image without such extras.

BOOKS=200000 npm run models && npm run build

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=eu-central-1
REPO=library-recommender
REGISTRY=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com

aws ecr create-repository --repository-name $REPO --region $REGION
aws ecr get-login-password --region $REGION | docker login -u AWS --password-stdin $REGISTRY

docker buildx build --platform linux/arm64 --provenance=false --sbom=false \
  -t $REGISTRY/$REPO:v1 --push .
Enter fullscreen mode Exit fullscreen mode

Step 6: Create the SOCI index

You run this step on a Linux machine with containerd. The easiest option is a small Graviton instance with Amazon Linux 2023, because its architecture already matches the image. The instance needs a role with read and write access to the ECR repository.

sudo yum install -y soci-snapshotter containerd nerdctl
sudo systemctl start soci-snapshotter
sudo systemctl restart containerd

ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REGION=eu-central-1
REPO=library-recommender
REGISTRY=$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com

aws ecr get-login-password --region $REGION | sudo nerdctl login -u AWS --password-stdin $REGISTRY

sudo nerdctl pull --platform linux/arm64 $REGISTRY/$REPO:v1
sudo nerdctl image convert --soci --platform linux/arm64 $REGISTRY/$REPO:v1 $REGISTRY/$REPO:v1-soci
sudo nerdctl push --platform linux/arm64 $REGISTRY/$REPO:v1-soci
Enter fullscreen mode Exit fullscreen mode

nerdctl image convert --soci produces an index in format v2, and that is the only format Lambda accepts. When you follow older guides on the web, check which format they produce: Lambda does not support indexes in format v1.

Step 7: Check the result in ECR

Before you touch the function, take a look at the new tag. The manifest behind v1-soci must have exactly two entries, and one of them must be the SOCI index.

aws ecr batch-get-image --repository-name $REPO --region $REGION \
  --image-ids imageTag=v1-soci \
  --accepted-media-types application/vnd.oci.image.index.v1+json \
  --query 'images[0].imageManifest' --output text | jq '.manifests | length, [.[] | .artifactType // .mediaType]'
Enter fullscreen mode Exit fullscreen mode

Expected result: the first line shows 2, and the list contains an entry with soci in its name. If you see a higher number, attestations or additional platforms are still attached to the image. In that case, rebuild the image as in step 5.

Step 8: Capacity provider and function with CDK

Since version 2.235.0, aws-cdk-lib has a dedicated CapacityProvider construct. If you do not pass your own, it also creates the operator role with the managed policy AWSLambdaManagedEC2ResourceOperator. You attach a function to the provider with addFunction. The function is then published automatically as $LATEST.PUBLISHED, so you do not have to create a version by hand.

// lib/library-stack.ts
import { CfnOutput, Duration, Stack, type StackProps } from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import type { Construct } from 'constructs';

export interface LibraryStackProps extends StackProps {
  repositoryName: string;
  /** Tag of the SOCI-indexed image, e.g. "v1-soci" */
  imageTag: string;
  /** Optional: original tag (e.g. "v1") for a comparison function without SOCI */
  plainImageTag?: string;
}

export class LibraryStack extends Stack {
  constructor(scope: Construct, id: string, props: LibraryStackProps) {
    super(scope, id, props);

    // Private subnets with a NAT gateway so the instances can reach ECR and CloudWatch
    const vpc = new ec2.Vpc(this, 'LibraryVpc', { maxAzs: 3, natGateways: 1 });
    const sg = new ec2.SecurityGroup(this, 'InstancesSg', { vpc, allowAllOutbound: true });

    const capacity = new lambda.CapacityProvider(this, 'LibraryCapacity', {
      subnets: vpc.privateSubnets,
      securityGroups: [sg],
      architectures: [lambda.Architecture.ARM_64],
      maxVCpuCount: 12,
    });

    const repo = ecr.Repository.fromRepositoryName(this, 'Repo', props.repositoryName);

    const addRecommender = (id: string, tag: string) => {
      const fn = new lambda.DockerImageFunction(this, id, {
        code: lambda.DockerImageCode.fromEcr(repo, { tagOrDigest: tag }),
        architecture: lambda.Architecture.ARM_64,
        memorySize: 4096,
        timeout: Duration.seconds(30),
        environment: { MODEL_FILE: '/opt/models/books-de.bin' },
      });
      capacity.addFunction(fn, {
        perExecutionEnvironmentMaxConcurrency: 8,
        latestPublishedScalingConfig: { minExecutionEnvironments: 3, maxExecutionEnvironments: 6 },
      });
      new CfnOutput(this, `${id}Name`, { value: fn.functionName });
      return fn;
    };

    addRecommender('Recommender', props.imageTag);
    if (props.plainImageTag) {
      addRecommender('RecommenderPlain', props.plainImageTag);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode
// bin/app.ts
import { App } from 'aws-cdk-lib';
import { LibraryStack } from '../lib/library-stack.ts';

const app = new App();
new LibraryStack(app, 'LibraryRecommender', {
  repositoryName: app.node.tryGetContext('repo') ?? 'library-recommender',
  imageTag: app.node.tryGetContext('tag') ?? 'v1-soci',
  plainImageTag: app.node.tryGetContext('plainTag'),
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },
});
Enter fullscreen mode Exit fullscreen mode
// cdk.json
{
  "app": "node --experimental-strip-types bin/app.ts"
}
Enter fullscreen mode Exit fullscreen mode

A few values in the stack are deliberate: according to CDK, maxVCpuCount must be between 12 and 15,000. perExecutionEnvironmentMaxConcurrency: 8 allows eight concurrent invocations per environment, which is rather generous for our compute-heavy handler. For pure CPU work, more concurrency per vCPU does not help much; more memory, and with it more vCPUs per function, helps more.

npx cdk bootstrap
npx cdk deploy -c repo=library-recommender -c tag=v1-soci -c plainTag=v1
Enter fullscreen mode Exit fullscreen mode

With -c plainTag=v1, the stack creates a second function that points to the image without a SOCI index. You need it for the comparison in the next step. The deployment takes a few minutes, because Lambda launches the instances.

Step 9: Measure startup time with and without SOCI

The script publishes a new version for each function and measures the time until it is Active. It is a rough but honest measurement: it includes pulling the image and initializing the environments.

// scripts/measure-start.ts
// Publishes a new version per function and measures the time until it is "Active".
// Usage: npm run measure -- recommender-plain recommender-soci
import {
  GetFunctionConfigurationCommand,
  LambdaClient,
  PublishVersionCommand,
} from '@aws-sdk/client-lambda';
import { setTimeout as sleep } from 'node:timers/promises';

const client = new LambdaClient({});

async function timeToActive(functionName: string): Promise<number> {
  const start = Date.now();
  const { Version } = await client.send(new PublishVersionCommand({ FunctionName: functionName }));
  for (;;) {
    const cfg = await client.send(
      new GetFunctionConfigurationCommand({ FunctionName: functionName, Qualifier: Version }),
    );
    if (cfg.State === 'Active') return (Date.now() - start) / 1000;
    if (cfg.State === 'Failed') throw new Error(`${functionName}:${Version} failed: ${cfg.StateReason}`);
    await sleep(5000);
  }
}

const names = process.argv.slice(2);
if (names.length === 0) {
  console.error('Please pass at least one function name');
  process.exit(1);
}
for (const name of names) {
  const seconds = await timeToActive(name);
  console.log(`${name}: active after ${seconds.toFixed(0)} s`);
}
Enter fullscreen mode Exit fullscreen mode
npm run measure -- <name of the plain function> <name of the SOCI function>
Enter fullscreen mode Exit fullscreen mode

You will find the function names in the outputs of cdk deploy. Measure several times and compare the averages, because network and placement vary. It is also worth checking CloudWatch Logs for the model loaded line and the duration of each first invocation. If you measure numbers with your own image, I would love to read about them in the comments.

Common mistakes with SOCI lazy loading

  • The function points to the old tag. The image URI must point to v1-soci, not to v1. Otherwise everything works, just without lazy loading.
  • Index in the wrong format. Lambda supports only SOCI Index Manifest v2. Check for the media type application/vnd.amazon.soci.index.v2+json.
  • Too many entries in the image index. Attestations, SBOMs or a multi-arch image lead to more than two entries in the index.
  • Files read only in the handler. Then the first invocation of every environment pays the loading time, and your latency metrics get outliers.
  • Mismatched architecture. Capacity provider, function and image must share the same architecture, here arm64 everywhere.
  • New image, old index. Every change to the image needs a new SOCI tag, for example v2-soci, and an updated image URI.

Costs and security

The announcement does not mention a separate price for SOCI itself. The index is stored as an additional artifact in ECR and is small compared to the image. The real costs come from Lambda Managed Instances: you pay the EC2 price of the instances plus a 15 percent management fee. Savings Plans and Reserved Instances apply only to the EC2 part. In this example, you also pay for a NAT gateway and the EC2 instance used for the SOCI step. Clean up after testing:

npx cdk destroy
aws ecr delete-repository --repository-name library-recommender --region eu-central-1 --force
Enter fullscreen mode Exit fullscreen mode

On the security side, the capacity provider is the security boundary. Functions that share a provider run as containers on the same instances. Separate functions with different protection needs by giving them their own capacity providers. The operator role needs only the managed policy, and the role of the build instance needs access to just the one repository.

My take

SOCI lazy loading solves a real problem, but a narrow one. It helps only container image functions on Lambda Managed Instances, not the classic Lambda most of us use every day. If you run large images there, an extra build step gets you faster starts when publishing and when scaling out. That is mainly interesting for ML workloads, where an image ships many models or libraries.

Where I am cautious: the build process gets more complicated. You need containerd and nerdctl, a Linux machine with the matching architecture and a second tagging scheme. That belongs in the CI pipeline, not on someone’s laptop. And lazy loading is no substitute for lean images. If your function ends up reading almost everything anyway, you gain little. My advice: clean up the image first, then measure, then add SOCI. By the way, for Java functions on regular Lambda, SnapStart remains the tool of choice for startup times.

FAQ

Does SOCI lazy loading work with regular Lambda?

No. As of late October 2026, Lambda supports SOCI only for functions that are packaged as container images and run on Lambda Managed Instances.

Do I have to change my function code for SOCI?

Not necessarily. You should, however, load all libraries and files in the init phase, so the first invocation of an environment is not slowed down by on-demand fetching.

Can I create the SOCI index with Docker?

The AWS documentation shows the path with containerd, nerdctl and the SOCI snapshotter. Docker alone does not create a SOCI index, so you need an environment with containerd for this step.

How much faster does my function start with SOCI?

AWS quotes startup times that are up to 70 percent shorter. How much you gain depends on which share of the image is actually read. Measure it with and without the index.

Your next step

Take your largest container image and find out which share of it is really read at startup. If it is less than half, and you are considering Lambda Managed Instances anyway, SOCI is a good candidate for an experiment. What makes your Lambda images so large: models, libraries or something else entirely?

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)